@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/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 TeqFw_Di_Container_Resolve_GraphResolver from './Container/GraphResolver.mjs';
12
- import TeqFw_Di_Container_Instantiate_Instantiator from './Container/Instantiate.mjs';
13
- import TeqFw_Di_Container_Lifecycle_Registry from './Container/Lifecycle.mjs';
14
- import TeqFw_Di_Container_Wrapper_Executor from './Container/Executor.mjs';
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 {TeqFw_Di_Container_Resolve_GraphResolver|undefined} */
49
+ /** @type {TeqFw_Di_Container_GraphResolver|undefined} */
50
50
  let graphResolver;
51
- /** @type {TeqFw_Di_Container_Lifecycle_Registry|undefined} */
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 {TeqFw_Di_Container_Instantiate_Instantiator} */
56
- const instantiator = new TeqFw_Di_Container_Instantiate_Instantiator();
57
- /** @type {TeqFw_Di_Container_Wrapper_Executor} */
58
- const wrapperExecutor = new TeqFw_Di_Container_Wrapper_Executor();
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
- const freeze = function (value) {
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
- try {
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 applyPostprocess = function (value) {
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
- for (const fn of postprocess) current = fn(current);
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 TeqFw_Di_Container_Resolve_GraphResolver({parser, resolver, logger});
108
- lifecycle = new TeqFw_Di_Container_Lifecycle_Registry(logger);
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 CDC.
199
+ * Registers a mock value for a Dependency Specifier.
176
200
  *
177
- * @param {string} cdc
201
+ * @param {string} specifier
178
202
  * @param {any} mock
179
203
  * @returns {void}
180
204
  */
181
- this.register = function (cdc, mock) {
205
+ this.register = function (specifier, mock) {
182
206
  assertBuilderStage();
183
- logBuilder(`register('${cdc}').`);
207
+ logBuilder(`register('${specifier}').`);
184
208
  if (testMode !== true) throw new Error('Container test mode is disabled.');
185
- const depId = parser.parse(cdc);
209
+ const depId = canonicalize(specifier);
186
210
  mockRegistry.set(getMockKey(depId), mock);
187
211
  };
188
212
 
189
213
  /**
190
- * Resolves a CDC into a frozen linked instance.
214
+ * Resolves a Dependency Specifier into a frozen Resolved Value.
191
215
  *
192
- * @param {string} cdc
216
+ * @param {string} specifier
193
217
  * @returns {Promise<any>}
194
218
  */
195
- this.get = async function (cdc) {
219
+ this.get = async function (specifier) {
196
220
  if (state === 'failed') {
197
- logger.error(`Container.get: rejected in failed state cdc='${cdc}'.`);
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
- applyPreprocess,
238
+ canonicalize,
216
239
  applyPostprocess,
217
- }, cdc);
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
@@ -47,7 +47,7 @@ export default class DTO {
47
47
  /** @type {string[]} Wrapper pipeline names. */
48
48
  wrappers;
49
49
 
50
- /** @type {string} Original CDC string. */
50
+ /** @type {string} Original Dependency Specifier string. */
51
51
  origin;
52
52
  }
53
53
 
@@ -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 Canonical dependency identity.
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 CDC strings.');
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
+ }