alloy-di 1.2.3 → 1.3.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.
@@ -0,0 +1,24 @@
1
+ //#region src/lib/env-detection.d.ts
2
+ type ImportMetaEnvShape = {
3
+ MODE?: string;
4
+ PROD?: boolean;
5
+ NODE_ENV?: string;
6
+ [key: string]: unknown;
7
+ };
8
+ type EnvDetectionOverrides = {
9
+ /**
10
+ * Explicit import.meta.env replacement. Use `null` to force "no env" behavior.
11
+ */
12
+ importMetaEnv?: ImportMetaEnvShape | null;
13
+ /**
14
+ * Explicit NODE_ENV replacement. Use `null` to ignore process.env.
15
+ */
16
+ nodeEnv?: string | null;
17
+ /**
18
+ * Short-circuit the entire detection logic with a predetermined boolean.
19
+ */
20
+ isDev?: boolean;
21
+ };
22
+ declare function setEnvDetectionOverrides(overrides: EnvDetectionOverrides | undefined): void;
23
+ //#endregion
24
+ export { EnvDetectionOverrides, setEnvDetectionOverrides };
@@ -2,11 +2,20 @@
2
2
  function isRecord(value) {
3
3
  return typeof value === "object" && value !== null;
4
4
  }
5
+ /**
6
+ * Build-time-injected detection overrides.
7
+ *
8
+ * The Vite plugin emits a `setEnvDetectionOverrides({ isDev: ... })` call
9
+ * into the generated container module so detection in plugin-driven setups
10
+ * uses the bundler's authoritative mode instead of runtime sniffing.
11
+ */
12
+ let injectedOverrides;
13
+ function setEnvDetectionOverrides(overrides) {
14
+ injectedOverrides = overrides;
15
+ }
5
16
  function readImportMetaEnvFromRuntime() {
6
17
  if (typeof import.meta === "undefined") return;
7
- const candidate = import.meta;
8
- if (!isRecord(candidate) || !("env" in candidate)) return;
9
- const envValue = candidate.env;
18
+ const envValue = import.meta.env;
10
19
  if (!isRecord(envValue)) return;
11
20
  const env = {};
12
21
  if (typeof envValue.MODE === "string") env.MODE = envValue.MODE;
@@ -15,8 +24,11 @@ function readImportMetaEnvFromRuntime() {
15
24
  return env;
16
25
  }
17
26
  function readProcessNodeEnv() {
18
- if (typeof process === "undefined") return;
19
- return "development";
27
+ try {
28
+ return "development";
29
+ } catch {
30
+ return;
31
+ }
20
32
  }
21
33
  function getImportMetaEnv(overrides) {
22
34
  if (overrides?.importMetaEnv === null) return;
@@ -29,14 +41,15 @@ function getNodeEnv(overrides) {
29
41
  return readProcessNodeEnv();
30
42
  }
31
43
  function isDevEnvironment(overrides) {
32
- if (typeof overrides?.isDev === "boolean") return overrides.isDev;
33
- const nodeEnv = getNodeEnv(overrides);
44
+ const effective = overrides ?? injectedOverrides;
45
+ if (typeof effective?.isDev === "boolean") return effective.isDev;
46
+ const nodeEnv = getNodeEnv(effective);
34
47
  if (typeof nodeEnv === "string") return nodeEnv !== "production";
35
- const importMetaEnv = getImportMetaEnv(overrides);
48
+ const importMetaEnv = getImportMetaEnv(effective);
36
49
  if (typeof importMetaEnv?.PROD === "boolean") return !importMetaEnv.PROD;
37
50
  if (typeof importMetaEnv?.MODE === "string") return importMetaEnv.MODE !== "production";
38
51
  if (typeof importMetaEnv?.NODE_ENV === "string") return importMetaEnv.NODE_ENV !== "production";
39
52
  return true;
40
53
  }
41
54
  //#endregion
42
- export { isDevEnvironment };
55
+ export { isDevEnvironment, setEnvDetectionOverrides };
@@ -1,6 +1,7 @@
1
1
  import { createClassKey, createSymbolKey, hashString, normalizeImportPath } from "./utils.js";
2
2
  import { IdentifierResolver } from "./identifier-resolver.js";
3
3
  import path from "node:path";
4
+ import ts from "typescript";
4
5
  //#region src/plugins/core/codegen.ts
5
6
  function escapeSingleQuotes(value) {
6
7
  return value.replaceAll("'", "\\'");
@@ -91,12 +92,72 @@ function resolveDependencyImports(metas, pool, serviceBindings, runtimeImports)
91
92
  importMap
92
93
  };
93
94
  }
95
+ /**
96
+ * True when an identifier occupies a name position rather than referencing a
97
+ * binding: property-access names (`ns.Api`), object keys — including method
98
+ * and accessor keys (`{ Api() {} }`, `{ get Api() {} }`) — class member
99
+ * names, qualified names, and destructuring property names.
100
+ */
101
+ function isNonReferencePosition(node) {
102
+ const parent = node.parent;
103
+ if (ts.isPropertyAccessExpression(parent)) return parent.name === node;
104
+ if (ts.isQualifiedName(parent)) return parent.right === node;
105
+ if (ts.isBindingElement(parent)) return parent.propertyName === node;
106
+ if (ts.isPropertyAssignment(parent) || ts.isMethodDeclaration(parent) || ts.isGetAccessorDeclaration(parent) || ts.isSetAccessorDeclaration(parent) || ts.isPropertyDeclaration(parent) || ts.isMethodSignature(parent) || ts.isPropertySignature(parent)) return parent.name === node;
107
+ return false;
108
+ }
109
+ /**
110
+ * Decide how to rewrite one identifier node, or skip it.
111
+ *
112
+ * Only binding references are rewritten: name positions and string/comment
113
+ * content keep their text. Shorthand properties expand
114
+ * (`{ Api }` -> `{ Api: Api_1 }`) so the key survives the rename.
115
+ */
116
+ function createIdentifierEdit(node, source, replacement) {
117
+ const parent = node.parent;
118
+ if (isNonReferencePosition(node)) return;
119
+ const start = node.getStart(source);
120
+ if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) return {
121
+ start,
122
+ end: node.end,
123
+ text: `${node.text}: ${replacement}`
124
+ };
125
+ return {
126
+ start,
127
+ end: node.end,
128
+ text: replacement
129
+ };
130
+ }
131
+ /**
132
+ * Rewrite identifier references inside a reconstructed dependency expression.
133
+ *
134
+ * The expression is parsed and identifier nodes are replaced by position, so
135
+ * `$`-prefixed names rewrite correctly and occurrences inside string literals
136
+ * (e.g. lazy `import('/src/Api')` specifiers) and comments are untouched —
137
+ * the previous `\b`-regex text replacement got both wrong.
138
+ */
139
+ function rewriteReferencedIdentifiers(expression, referenced, rewriter) {
140
+ const wrapped = `(${expression});`;
141
+ const source = ts.createSourceFile("alloy-dependency-expression.ts", wrapped, ts.ScriptTarget.ESNext, true);
142
+ const edits = [];
143
+ const visit = (node) => {
144
+ if (ts.isIdentifier(node) && referenced.has(node.text)) {
145
+ const replacement = rewriter(node.text);
146
+ if (replacement && replacement !== node.text) {
147
+ const edit = createIdentifierEdit(node, source, replacement);
148
+ if (edit) edits.push(edit);
149
+ }
150
+ }
151
+ ts.forEachChild(node, visit);
152
+ };
153
+ visit(source);
154
+ let result = wrapped;
155
+ for (const edit of edits.toSorted((a, b) => b.start - a.start)) result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
156
+ return result.slice(1, -2);
157
+ }
94
158
  function reconstructDependencyExpression(dep, rewriter, contextDir) {
95
159
  let expr = dep.expression;
96
- for (const ident of dep.referencedIdentifiers) {
97
- const replacement = rewriter(ident);
98
- if (replacement && replacement !== ident) expr = expr.replaceAll(new RegExp(`\\b${ident}\\b`, "g"), replacement);
99
- }
160
+ if (dep.referencedIdentifiers.length > 0) expr = rewriteReferencedIdentifiers(expr, new Set(dep.referencedIdentifiers), rewriter);
100
161
  if (dep.isLazy) expr = expr.replaceAll(/import\s*\(\s*(['"])(.+?)\1\s*\)/g, (match, quote, importPath) => {
101
162
  if (importPath.startsWith(".")) return `import(${quote}${normalizeImportPath(path.resolve(contextDir, importPath))}${quote})`;
102
163
  return match;
@@ -128,11 +189,11 @@ function reconstructOptionsText(meta, importMap, serviceRenames) {
128
189
  if (parts.length === 0) return "{}";
129
190
  return `{ ${parts.join(", ")} }`;
130
191
  }
131
- function buildImportsAndRegistrations(metas, lazyReferencedClassKeys, providerModulePaths) {
192
+ function buildImportsAndRegistrations(metas, lazyReferencedClassKeys, providerModulePaths, options) {
132
193
  const hasProviderModules = providerModulePaths.length > 0;
133
194
  const activeMetas = filterActiveMetas(metas, lazyReferencedClassKeys);
134
195
  const resolver = new IdentifierResolver(activeMetas);
135
- const runtimeImports = computeRuntimeImports(activeMetas, hasProviderModules);
196
+ const runtimeImports = computeRuntimeImports(activeMetas, hasProviderModules, options?.isDev !== void 0);
136
197
  const pool = createNamePool([
137
198
  ...runtimeImports,
138
199
  "registrations",
@@ -189,8 +250,9 @@ function enrichRegistrations(activeMetas, naming) {
189
250
  };
190
251
  });
191
252
  }
192
- function computeRuntimeImports(activeMetas, hasProviderModules) {
253
+ function computeRuntimeImports(activeMetas, hasProviderModules, hasEnvOverrides = false) {
193
254
  const imports = new Set(["Container", "dependenciesRegistry"]);
255
+ if (hasEnvOverrides) imports.add("setEnvDetectionOverrides");
194
256
  const needsLazyImport = activeMetas.some((m) => m.metadata.dependencies.some((d) => d.isLazy) || !!m.metadata.factory);
195
257
  if (hasProviderModules) imports.add("applyProviders");
196
258
  if (needsLazyImport) imports.add("Lazy");
@@ -257,9 +319,10 @@ function createIdentifierExports(registrations) {
257
319
  * @param lazyReferencedClassKeys - Set of service keys that are referenced ONLY lazily (and thus should not be imported/registered eagerly in this bundle).
258
320
  * @param providerModulePaths - List of provider modules to import and apply.
259
321
  */
260
- function generateContainerModule(metas, lazyReferencedClassKeys, providerModulePaths) {
322
+ function generateContainerModule(metas, lazyReferencedClassKeys, providerModulePaths, options) {
261
323
  const hasProviderModules = providerModulePaths.length > 0;
262
- const { runtimeImportStatement, registrationsBlock, stubsBlock, identifierExportBlock } = buildImportsAndRegistrations(metas, lazyReferencedClassKeys, providerModulePaths);
324
+ const { runtimeImportStatement, registrationsBlock, stubsBlock, identifierExportBlock } = buildImportsAndRegistrations(metas, lazyReferencedClassKeys, providerModulePaths, options);
325
+ const envOverridesBlock = options?.isDev === void 0 ? "" : `\nsetEnvDetectionOverrides({ isDev: ${options.isDev} });\n`;
263
326
  let providerImportBlock = "";
264
327
  let providerInvocationBlock = "";
265
328
  if (hasProviderModules) {
@@ -268,7 +331,7 @@ function generateContainerModule(metas, lazyReferencedClassKeys, providerModuleP
268
331
  providerInvocationBlock = `\nconst providerDefinitions = [${aliasNames.join(", ")}];\nfor (const definition of providerDefinitions) {\n applyProviders(container, definition);\n}\n`;
269
332
  }
270
333
  return `
271
- ${runtimeImportStatement}${stubsBlock}
334
+ ${runtimeImportStatement}${envOverridesBlock}${stubsBlock}
272
335
  ${providerImportBlock}
273
336
  ${registrationsBlock}
274
337
 
@@ -281,6 +344,11 @@ ${providerInvocationBlock}${identifierExportBlock}
281
344
  export default container;
282
345
  `;
283
346
  }
347
+ const GENERATED_FILE_NOTICE = "This file was auto-generated by Alloy. Manual changes will be overwritten.";
348
+ const GENERATED_FILE_HEADER = `/**
349
+ * ${GENERATED_FILE_NOTICE}
350
+ */
351
+ `;
284
352
  /**
285
353
  * Generates the TypeScript declaration definition (`.d.ts`) for the virtual container module.
286
354
  * It exports the `ServiceIdentifiers` interface matching the runtime exports.
@@ -305,7 +373,7 @@ function generateContainerTypeDefinition(metas, pathResolver) {
305
373
  const exportKey = createIdentifierExportKey(meta, resolver);
306
374
  interfaceMembers.push(`${exportKey}: ServiceIdentifier<${importName}>;`);
307
375
  }
308
- return `
376
+ return `${GENERATED_FILE_HEADER}
309
377
  declare module "virtual:alloy-container" {
310
378
  import { Container, ServiceIdentifier } from "alloy-di/runtime";
311
379
  ${imports.length ? imports.join("\n") + "\n" : ""}
@@ -329,7 +397,7 @@ declare module "virtual:alloy-container" {
329
397
  * @param manifests - List of loaded manifest info (packageName and services).
330
398
  */
331
399
  function generateManifestTypeDefinition(manifests) {
332
- return manifests.map((m) => {
400
+ return GENERATED_FILE_HEADER + manifests.map((m) => {
333
401
  const serviceIdentifiers = m.services.map((s) => ` export const ${s.exportName}Identifier: ServiceIdentifier;`).join("\n");
334
402
  return `
335
403
  declare module "${m.packageName}/manifest" {
@@ -420,4 +488,4 @@ ${serviceIdentifiers}
420
488
  }).join("\n");
421
489
  }
422
490
  //#endregion
423
- export { generateContainerModule, generateContainerTypeDefinition, generateManifestTypeDefinition };
491
+ export { GENERATED_FILE_NOTICE, generateContainerModule, generateContainerTypeDefinition, generateManifestTypeDefinition };
@@ -1,12 +1,12 @@
1
- import { normalizeImportPath } from "../core/utils.js";
2
- import { IdentifierResolver } from "../core/identifier-resolver.js";
3
- import { generateContainerModule, generateContainerTypeDefinition, generateManifestTypeDefinition } from "../core/codegen.js";
1
+ import { normalizeImportPath, writeFileIfChanged } from "./utils.js";
2
+ import { IdentifierResolver } from "./identifier-resolver.js";
3
+ import { GENERATED_FILE_NOTICE, generateContainerModule, generateContainerTypeDefinition, generateManifestTypeDefinition } from "./codegen.js";
4
4
  import { augmentFactoryLazyServices, collectEagerReferencedNames, findDuplicateManifestServices, groupMetasByName, readManifests, reconcileLazySet, toMetaFromManifest } from "./manifest-utils.js";
5
5
  import { generateMermaidDiagram } from "./visualizer.js";
6
6
  import { ensureDirectoryForFile } from "./visualization-utils.js";
7
7
  import path from "node:path";
8
8
  import fs from "node:fs";
9
- //#region src/plugins/vite-plugin/container-loader.ts
9
+ //#region src/plugins/core/container-loader.ts
10
10
  async function loadVirtualContainerModule(options) {
11
11
  const metas = options.localMetas.map((meta) => ({
12
12
  ...meta,
@@ -32,7 +32,7 @@ async function loadVirtualContainerModule(options) {
32
32
  const providerImports = Array.from(new Set([...options.providerImportPaths, ...manifestData.providers]));
33
33
  reconcileLazySet(metas, lazyClassKeys, collectEagerReferencedNames(metas));
34
34
  augmentFactoryLazyServices(metas, options.lazyServiceKeys);
35
- const code = generateContainerModule(metas, lazyClassKeys, providerImports);
35
+ const code = generateContainerModule(metas, lazyClassKeys, providerImports, { isDev: options.isDevMode });
36
36
  writeTypeDefinitions(metas, loadedManifests, options.resolvedRoot, options.containerDeclarationDir);
37
37
  writeVisualizationArtifact(metas, lazyClassKeys, options.resolvedVisualization);
38
38
  return {
@@ -66,13 +66,13 @@ function writeTypeDefinitions(metas, loadedManifests, resolvedRoot, containerDec
66
66
  const dtsDir = path.resolve(resolvedRoot, containerDeclarationDir ?? "./src");
67
67
  const dtsContent = generateContainerTypeDefinition(metas, (filePath) => resolveDeclarationImportPath(dtsDir, filePath));
68
68
  if (!fs.existsSync(dtsDir)) fs.mkdirSync(dtsDir, { recursive: true });
69
- fs.writeFileSync(path.join(dtsDir, "alloy-container.d.ts"), dtsContent);
69
+ writeFileIfChanged(path.join(dtsDir, "alloy-container.d.ts"), dtsContent);
70
70
  if (loadedManifests.length === 0) return;
71
71
  const manifestsDts = generateManifestTypeDefinition(loadedManifests.map((m) => ({
72
72
  packageName: m.packageName,
73
73
  services: m.services
74
74
  })));
75
- fs.writeFileSync(path.join(dtsDir, "alloy-manifests.d.ts"), manifestsDts);
75
+ writeFileIfChanged(path.join(dtsDir, "alloy-manifests.d.ts"), manifestsDts);
76
76
  }
77
77
  function resolveDeclarationImportPath(dtsDir, filePath) {
78
78
  if (!path.isAbsolute(filePath)) return filePath;
@@ -89,7 +89,7 @@ function writeVisualizationArtifact(metas, lazyReferencedClassKeys, resolvedVisu
89
89
  options: resolvedVisualization.mermaidOptions
90
90
  });
91
91
  ensureDirectoryForFile(resolvedVisualization.outputPath);
92
- fs.writeFileSync(resolvedVisualization.outputPath, `${artifact.diagram}\n`);
92
+ writeFileIfChanged(resolvedVisualization.outputPath, `%% ${GENERATED_FILE_NOTICE}\n${artifact.diagram}\n`);
93
93
  }
94
94
  //#endregion
95
95
  export { loadVirtualContainerModule };
@@ -1,6 +1,6 @@
1
- import { createClassKey } from "../core/utils.js";
2
- import { createDiscoveryStore } from "../core/discovery-store.js";
3
- //#region src/plugins/vite-plugin/discovery-runtime.ts
1
+ import { createClassKey } from "./utils.js";
2
+ import { createDiscoveryStore } from "./discovery-store.js";
3
+ //#region src/plugins/core/discovery-runtime.ts
4
4
  /** Files the discovery scanner processes (mirrors the transform hook filter). */
5
5
  function isDiscoverableFile(file) {
6
6
  return /\.tsx?$/i.test(file) && !/\.d\.ts$/i.test(file) && !file.includes("node_modules");
@@ -62,15 +62,5 @@ function createDiscoveryRuntime() {
62
62
  }
63
63
  };
64
64
  }
65
- /**
66
- * Invalidate the generated container module in every environment's module
67
- * graph so its `load` hook re-runs and regenerates from current discovery.
68
- */
69
- function invalidateContainerModule(server, resolvedVirtualModuleId) {
70
- for (const environment of Object.values(server.environments)) {
71
- const mod = environment.moduleGraph.getModuleById(resolvedVirtualModuleId);
72
- if (mod) environment.moduleGraph.invalidateModule(mod);
73
- }
74
- }
75
65
  //#endregion
76
- export { createDiscoveryRuntime, invalidateContainerModule, isDiscoverableFile };
66
+ export { createDiscoveryRuntime, isDiscoverableFile };
@@ -1,10 +1,14 @@
1
1
  import { scanSource } from "./scanner.js";
2
+ import crypto from "node:crypto";
2
3
  //#region src/plugins/core/discovery-store.ts
3
4
  /**
4
5
  * Maintains a per-file cache of discovered DI metadata, lazy references, and
5
6
  * optionally source snapshots to drive incremental recompilation inside the
6
7
  * Alloy discovery pipeline.
7
8
  */
9
+ function hashContent(code) {
10
+ return crypto.createHash("sha1").update(code).digest("hex");
11
+ }
8
12
  /**
9
13
  * Creates a file-scoped discovery store that caches scanner output and
10
14
  * optionally the original source for diagnostics or incremental rebuilds.
@@ -15,6 +19,7 @@ import { scanSource } from "./scanner.js";
15
19
  function createDiscoveryStore(options = {}) {
16
20
  const fileMetas = /* @__PURE__ */ new Map();
17
21
  const fileLazyRefs = /* @__PURE__ */ new Map();
22
+ const fileContentHashes = /* @__PURE__ */ new Map();
18
23
  const fileSources = options.trackSources ? /* @__PURE__ */ new Map() : void 0;
19
24
  /**
20
25
  * Scan and cache the latest metadata for a file, returning both the fresh
@@ -26,6 +31,14 @@ function createDiscoveryStore(options = {}) {
26
31
  function updateFile(id, code) {
27
32
  const previousMetas = fileMetas.get(id);
28
33
  const previousLazyClassKeys = fileLazyRefs.get(id);
34
+ const contentHash = hashContent(code);
35
+ if (fileContentHashes.get(id) === contentHash) return {
36
+ metas: previousMetas ?? [],
37
+ lazyClassKeys: new Set(previousLazyClassKeys),
38
+ previousMetas,
39
+ previousLazyClassKeys
40
+ };
41
+ fileContentHashes.set(id, contentHash);
29
42
  if (fileSources) fileSources.set(id, code);
30
43
  const { metas, lazyClassKeys } = scanSource(code, id);
31
44
  if (metas.length) fileMetas.set(id, metas);
@@ -49,6 +62,7 @@ function createDiscoveryStore(options = {}) {
49
62
  const previousLazyClassKeys = fileLazyRefs.get(id);
50
63
  fileMetas.delete(id);
51
64
  fileLazyRefs.delete(id);
65
+ fileContentHashes.delete(id);
52
66
  if (fileSources) fileSources.delete(id);
53
67
  return {
54
68
  previousMetas,
@@ -61,6 +75,7 @@ function createDiscoveryStore(options = {}) {
61
75
  function clear() {
62
76
  fileMetas.clear();
63
77
  fileLazyRefs.clear();
78
+ fileContentHashes.clear();
64
79
  fileSources?.clear();
65
80
  }
66
81
  return {
@@ -1,6 +1,6 @@
1
- import { createClassKey, createSymbolKey, normalizeImportPath } from "../core/utils.js";
1
+ import { createClassKey, createSymbolKey, normalizeImportPath } from "./utils.js";
2
2
  import { z } from "zod";
3
- //#region src/plugins/vite-plugin/manifest-utils.ts
3
+ //#region src/plugins/core/manifest-utils.ts
4
4
  /**
5
5
  * Reads a list of manifest objects and returns aggregated service + provider module specifiers.
6
6
  *
@@ -83,9 +83,16 @@ async function readManifests(inputs) {
83
83
  function readManifestByVersion(manifest) {
84
84
  return (manifest.schemaVersion ?? 1) === 2 ? readManifestV2(manifest) : readManifestV1(manifest);
85
85
  }
86
+ function warnInvalidManifest(manifest, error) {
87
+ const packageName = typeof manifest.packageName === "string" && manifest.packageName ? manifest.packageName : "<unknown package>";
88
+ console.warn(`[alloy] Ignoring invalid manifest "${packageName}" — its services and providers will not be registered:\n${z.prettifyError(error)}`);
89
+ }
86
90
  function readManifestV1(manifest) {
87
91
  const parsed = manifestSchemaV1.safeParse(manifest);
88
- if (!parsed.success) return null;
92
+ if (!parsed.success) {
93
+ warnInvalidManifest(manifest, parsed.error);
94
+ return null;
95
+ }
89
96
  return {
90
97
  schemaVersion: 1,
91
98
  packageName: parsed.data.packageName,
@@ -98,7 +105,10 @@ function readManifestV1(manifest) {
98
105
  }
99
106
  function readManifestV2(manifest) {
100
107
  const parsed = manifestSchemaV2.safeParse(manifest);
101
- if (!parsed.success) return null;
108
+ if (!parsed.success) {
109
+ warnInvalidManifest(manifest, parsed.error);
110
+ return null;
111
+ }
102
112
  return {
103
113
  schemaVersion: 2,
104
114
  packageName: parsed.data.packageName,
@@ -36,7 +36,19 @@ function collectFileImports(sourceFile) {
36
36
  }
37
37
  return imports;
38
38
  }
39
+ /**
40
+ * Cheap pre-filter that avoids the TS parse for files that cannot contribute
41
+ * discovery results: decorators require an `@` and lazy references require
42
+ * the `Lazy` identifier.
43
+ */
44
+ function mayContainDiscoverableSyntax(code) {
45
+ return code.includes("@") || code.includes("Lazy");
46
+ }
39
47
  function scanSource(code, id) {
48
+ if (!mayContainDiscoverableSyntax(code)) return {
49
+ metas: [],
50
+ lazyClassKeys: /* @__PURE__ */ new Set()
51
+ };
40
52
  const sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.ESNext, true);
41
53
  const discovered = /* @__PURE__ */ new Map();
42
54
  const lazyRefs = /* @__PURE__ */ new Set();
@@ -30,14 +30,51 @@ function createAliasName(className, filePath) {
30
30
  function createSymbolKey(filePath, className) {
31
31
  return `alloy:${normalizeImportPath(filePath)}#${className}`;
32
32
  }
33
- function walkSync(dir, fileList = []) {
34
- if (!fs.existsSync(dir)) return fileList;
35
- fs.readdirSync(dir).forEach((file) => {
36
- const filePath = path.join(dir, file);
37
- if (fs.statSync(filePath).isDirectory()) walkSync(filePath, fileList);
38
- else fileList.push(filePath);
39
- });
33
+ const lastWrittenContent = /* @__PURE__ */ new Map();
34
+ /**
35
+ * Write a generated artifact only when its content actually changed.
36
+ *
37
+ * @returns true when the file was written, false when it already matched.
38
+ */
39
+ function writeFileIfChanged(filePath, content) {
40
+ if (lastWrittenContent.get(filePath) === content) return false;
41
+ try {
42
+ if (fs.readFileSync(filePath, "utf-8") === content) {
43
+ lastWrittenContent.set(filePath, content);
44
+ return false;
45
+ }
46
+ } catch {}
47
+ fs.writeFileSync(filePath, content);
48
+ lastWrittenContent.set(filePath, content);
49
+ return true;
50
+ }
51
+ function walkSync(dir, fileList = [], visitedDirs) {
52
+ const visited = visitedDirs ?? /* @__PURE__ */ new Set();
53
+ let realDir;
54
+ try {
55
+ realDir = fs.realpathSync(dir);
56
+ } catch {
57
+ return fileList;
58
+ }
59
+ if (visited.has(realDir)) return fileList;
60
+ visited.add(realDir);
61
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
62
+ if (entry.name.startsWith(".")) continue;
63
+ const filePath = path.join(dir, entry.name);
64
+ if (entry.isDirectory()) walkSync(filePath, fileList, visited);
65
+ else if (entry.isFile()) fileList.push(filePath);
66
+ else if (entry.isSymbolicLink()) {
67
+ let stat;
68
+ try {
69
+ stat = fs.statSync(filePath);
70
+ } catch {
71
+ continue;
72
+ }
73
+ if (stat.isDirectory()) walkSync(filePath, fileList, visited);
74
+ else if (stat.isFile()) fileList.push(filePath);
75
+ }
76
+ }
40
77
  return fileList;
41
78
  }
42
79
  //#endregion
43
- export { createAliasName, createClassKey, createSymbolKey, hashString, normalizeImportPath, walkSync };
80
+ export { createAliasName, createClassKey, createSymbolKey, hashString, normalizeImportPath, walkSync, writeFileIfChanged };
@@ -1,6 +1,6 @@
1
1
  import { MermaidDiagramOptions } from "./visualizer.js";
2
2
 
3
- //#region src/plugins/vite-plugin/visualization-utils.d.ts
3
+ //#region src/plugins/core/visualization-utils.d.ts
4
4
  interface AlloyMermaidVisualizerOptions extends MermaidDiagramOptions {
5
5
  outputPath?: string;
6
6
  }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs";
3
- //#region src/plugins/vite-plugin/visualization-utils.ts
3
+ //#region src/plugins/core/visualization-utils.ts
4
4
  const DEFAULT_MERMAID_FILENAME = "alloy-di.mmd";
5
5
  function resolveVisualizationOptions(input, projectRoot) {
6
6
  if (!input) return null;
@@ -1,6 +1,6 @@
1
1
  import { ServiceScope } from "../../lib/scope.js";
2
2
 
3
- //#region src/plugins/vite-plugin/visualizer.d.ts
3
+ //#region src/plugins/core/visualizer.d.ts
4
4
  interface MermaidDiagramOptions {
5
5
  direction?: "LR" | "TB" | "BT" | "RL";
6
6
  includeLegend?: boolean;
@@ -1,22 +1,22 @@
1
- import { createClassKey, createSymbolKey, hashString, normalizeImportPath } from "../core/utils.js";
1
+ import { createClassKey, createSymbolKey, hashString, normalizeImportPath } from "./utils.js";
2
2
  import path from "node:path";
3
- //#region src/plugins/vite-plugin/visualizer.ts
3
+ //#region src/plugins/core/visualizer.ts
4
4
  const DEFAULT_SCOPE_COLORS = {
5
- singleton: "#f6c14a",
6
- transient: "#58a6ff"
5
+ singleton: "#3b6ea5",
6
+ transient: "#2a7d73"
7
7
  };
8
8
  const DEFAULT_OPTIONS = {
9
9
  direction: "LR",
10
10
  includeLegend: true,
11
11
  scopeColors: DEFAULT_SCOPE_COLORS,
12
- lazyNodeFill: "#e8def8",
13
- factoryNodeFill: "#ffe0b2",
14
- tokenNodeFill: "#d1d5db",
15
- nodeStrokeColor: "#1f2937",
16
- nodeTextColor: "#111827",
17
- lazyEdgeColor: "#a855f7",
18
- eagerEdgeColor: "#6b7280",
19
- factoryEdgeColor: "#ef6c00"
12
+ lazyNodeFill: "#6c5cb8",
13
+ factoryNodeFill: "#9c6516",
14
+ tokenNodeFill: "#4b5c6b",
15
+ nodeStrokeColor: "#5a7488",
16
+ nodeTextColor: "#ffffff",
17
+ lazyEdgeColor: "#9385d6",
18
+ eagerEdgeColor: "#7c93a6",
19
+ factoryEdgeColor: "#c2922e"
20
20
  };
21
21
  const RESERVED_IDENTIFIERS = new Set([
22
22
  "Lazy",
@@ -95,7 +95,7 @@ function generateMermaidDiagram({ metas, lazyClassKeys, options }) {
95
95
  edges.push({
96
96
  from: sourceNode,
97
97
  to: target,
98
- label: describeEdge(sourceNode, target, dep.isLazy),
98
+ label: describeEdge(sourceNode, target),
99
99
  isLazy: dep.isLazy,
100
100
  stroke: selectEdgeColor(dep.isLazy, target, mergedOptions)
101
101
  });
@@ -106,6 +106,7 @@ function generateMermaidDiagram({ metas, lazyClassKeys, options }) {
106
106
  if (mergedOptions.includeLegend) {
107
107
  lines.push(` %% Legend: singleton=${mergedOptions.scopeColors.singleton}, transient=${mergedOptions.scopeColors.transient}, lazy-only=${mergedOptions.lazyNodeFill}, factory=${mergedOptions.factoryNodeFill}, token=${mergedOptions.tokenNodeFill}`);
108
108
  lines.push(` %% Edge colors: eager=${mergedOptions.eagerEdgeColor}, lazy=${mergedOptions.lazyEdgeColor}, factory=${mergedOptions.factoryEdgeColor}`);
109
+ lines.push(` %% Edge labels: Si=singleton, Tr=transient, Tk=token; solid=eager, dotted=lazy`);
109
110
  }
110
111
  const allNodes = [...serviceNodes, ...Array.from(tokenNodes.values())];
111
112
  for (const node of allNodes) {
@@ -155,11 +156,18 @@ function sanitizeMermaidId(source, fallbackIndex) {
155
156
  function escapeMermaidLabel(label) {
156
157
  return label.replaceAll("\"", "\\\"").replaceAll("|", "/");
157
158
  }
159
+ /** Single-token code for a node's scope/kind: Si, Tr, or Tk (token). */
160
+ function scopeCode(node) {
161
+ if (node.type === "token") return "Tk";
162
+ return node.scope === "transient" ? "Tr" : "Si";
163
+ }
158
164
  /**
159
- * Builds a human-readable label describing the nature of an edge between two nodes.
165
+ * Builds a compact edge label as a `source→target` scope transition (e.g.
166
+ * `Si→Tr`). Eager/lazy is conveyed by the arrow style and the target kind by
167
+ * node color, so they are intentionally omitted from the text. See the legend.
160
168
  */
161
- function describeEdge(from, to, depIsLazy) {
162
- return `${depIsLazy ? "Lazy" : "Eager"} · ${from.scope ?? "unknown"}→${to.type === "token" ? "token" : to.scope ?? "unknown"} · ${to.type === "token" ? "Token" : to.hasFactory ? "Factory" : "Class"}`;
169
+ function describeEdge(from, to) {
170
+ return `${scopeCode(from)}→${scopeCode(to)}`;
163
171
  }
164
172
  /**
165
173
  * Determines the fill color for a node based on its type, scope, and lazy/factory flags.
@@ -1,6 +1,6 @@
1
1
  import { ServiceIdentifier } from "../../lib/service-identifiers.js";
2
2
  import { AlloyManifest } from "../core/types.js";
3
- import { AlloyMermaidVisualizerOptions, AlloyVisualizationOptions } from "./visualization-utils.js";
3
+ import { AlloyMermaidVisualizerOptions, AlloyVisualizationOptions } from "../core/visualization-utils.js";
4
4
  import { Plugin } from "vite";
5
5
 
6
6
  //#region src/plugins/vite-plugin/index.d.ts
@@ -1,7 +1,8 @@
1
1
  import { normalizeImportPath, walkSync } from "../core/utils.js";
2
- import { resolveVisualizationOptions } from "./visualization-utils.js";
3
- import { loadVirtualContainerModule } from "./container-loader.js";
4
- import { createDiscoveryRuntime, invalidateContainerModule, isDiscoverableFile } from "./discovery-runtime.js";
2
+ import { resolveVisualizationOptions } from "../core/visualization-utils.js";
3
+ import { loadVirtualContainerModule } from "../core/container-loader.js";
4
+ import { createDiscoveryRuntime, isDiscoverableFile } from "../core/discovery-runtime.js";
5
+ import { invalidateContainerModule } from "./module-invalidation.js";
5
6
  import path from "node:path";
6
7
  import fs from "node:fs";
7
8
  //#region src/plugins/vite-plugin/index.ts
@@ -22,6 +23,7 @@ function alloy(options = {}) {
22
23
  let resolvedRoot = process.cwd();
23
24
  let packageName = "UNKNOWN_PACKAGE";
24
25
  let resolvedVisualization = null;
26
+ let isDevMode;
25
27
  const lazyServiceKeys = new Set((options.lazyServices ?? []).map(toLazyServiceKey));
26
28
  const discoveryRuntime = createDiscoveryRuntime();
27
29
  return {
@@ -29,6 +31,7 @@ function alloy(options = {}) {
29
31
  enforce: "pre",
30
32
  configResolved(config) {
31
33
  resolvedRoot = config.root ?? process.cwd();
34
+ isDevMode = !config.isProduction;
32
35
  try {
33
36
  const pkgPath = path.resolve(resolvedRoot, "package.json");
34
37
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
@@ -102,7 +105,8 @@ function alloy(options = {}) {
102
105
  packageName,
103
106
  resolvedRoot,
104
107
  containerDeclarationDir: options.containerDeclarationDir,
105
- resolvedVisualization
108
+ resolvedVisualization,
109
+ isDevMode
106
110
  });
107
111
  }
108
112
  }
@@ -0,0 +1,13 @@
1
+ //#region src/plugins/vite-plugin/module-invalidation.ts
2
+ /**
3
+ * Invalidate the generated container module in every environment's module
4
+ * graph so its `load` hook re-runs and regenerates from current discovery.
5
+ */
6
+ function invalidateContainerModule(server, resolvedVirtualModuleId) {
7
+ for (const environment of Object.values(server.environments)) {
8
+ const mod = environment.moduleGraph.getModuleById(resolvedVirtualModuleId);
9
+ if (mod) environment.moduleGraph.invalidateModule(mod);
10
+ }
11
+ }
12
+ //#endregion
13
+ export { invalidateContainerModule };
package/dist/runtime.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Newable, Token, createToken } from "./lib/types.js";
2
2
  import { ServiceIdentifier, clearServiceIdentifierRegistry, getConstructorByIdentifier, getServiceIdentifier, registerServiceIdentifier } from "./lib/service-identifiers.js";
3
3
  import { Container } from "./lib/container.js";
4
+ import { EnvDetectionOverrides, setEnvDetectionOverrides } from "./lib/env-detection.js";
4
5
  import { LAZY_IDENTIFIER, Lazy } from "./lib/lazy.js";
5
6
  import { Injectable, Singleton, assertDeps, dependenciesRegistry, deps } from "./lib/decorators.js";
6
7
  import { ProviderDefinitions, applyProviders, asClass, asLazyClass, asValue, defineProviders, lifecycle } from "./lib/providers.js";
7
- export { Container, Injectable, LAZY_IDENTIFIER, Lazy, type Lazy as LazyInterface, type Newable, type ProviderDefinitions, type ServiceIdentifier, Singleton, type Token, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier };
8
+ export { Container, type EnvDetectionOverrides, Injectable, LAZY_IDENTIFIER, Lazy, type Lazy as LazyInterface, type Newable, type ProviderDefinitions, type ServiceIdentifier, Singleton, type Token, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier, setEnvDetectionOverrides };
package/dist/runtime.js CHANGED
@@ -2,6 +2,7 @@ import { createToken } from "./lib/types.js";
2
2
  import { LAZY_IDENTIFIER, Lazy } from "./lib/lazy.js";
3
3
  import { Injectable, Singleton, assertDeps, dependenciesRegistry, deps } from "./lib/decorators.js";
4
4
  import { clearServiceIdentifierRegistry, getConstructorByIdentifier, getServiceIdentifier, registerServiceIdentifier } from "./lib/service-identifiers.js";
5
+ import { setEnvDetectionOverrides } from "./lib/env-detection.js";
5
6
  import { Container } from "./lib/container.js";
6
7
  import { applyProviders, asClass, asLazyClass, asValue, defineProviders, lifecycle } from "./lib/providers.js";
7
- export { Container, Injectable, LAZY_IDENTIFIER, Lazy, Singleton, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier };
8
+ export { Container, Injectable, LAZY_IDENTIFIER, Lazy, Singleton, applyProviders, asClass, asLazyClass, asValue, assertDeps, clearServiceIdentifierRegistry, createToken, defineProviders, dependenciesRegistry, deps, getConstructorByIdentifier, getServiceIdentifier, lifecycle, registerServiceIdentifier, setEnvDetectionOverrides };
@@ -1 +1 @@
1
- {"root":["../src/entry-points.test.ts","../src/rollup.ts","../src/runtime.ts","../src/test.ts","../src/vite.ts","../src/lib/container.identifiers.test.ts","../src/lib/container.internals.test.ts","../src/lib/container.test.ts","../src/lib/container.testing-features.test.ts","../src/lib/container.ts","../src/lib/decorators.runtime.test.ts","../src/lib/decorators.test-d.ts","../src/lib/decorators.ts","../src/lib/dependency-error.ts","../src/lib/env-detection.test.ts","../src/lib/env-detection.ts","../src/lib/lazy-retry.test.ts","../src/lib/lazy.ts","../src/lib/providers.test.ts","../src/lib/providers.ts","../src/lib/scope.ts","../src/lib/service-identifiers.test.ts","../src/lib/service-identifiers.ts","../src/lib/tokens.test.ts","../src/lib/types.ts","../src/lib/testing/mocking.test.ts","../src/lib/testing/mocking.ts","../src/lib/testing/registry.test.ts","../src/lib/testing/registry.ts","../src/plugins/core/codegen.test.ts","../src/plugins/core/codegen.ts","../src/plugins/core/decorators.helpers.test.ts","../src/plugins/core/decorators.ts","../src/plugins/core/discovery-store.ts","../src/plugins/core/identifier-resolver.test.ts","../src/plugins/core/identifier-resolver.ts","../src/plugins/core/lazy-utils.ts","../src/plugins/core/lazy.helpers.test.ts","../src/plugins/core/lazy.ts","../src/plugins/core/scanner.test.ts","../src/plugins/core/scanner.ts","../src/plugins/core/types.ts","../src/plugins/core/utils.ts","../src/plugins/rollup-plugin/build-utils.ts","../src/plugins/rollup-plugin/index.ts","../src/plugins/rollup-plugin/rollup-plugin.test.ts","../src/plugins/vite-plugin/codegen.specifier.test.ts","../src/plugins/vite-plugin/container-loader.test.ts","../src/plugins/vite-plugin/container-loader.ts","../src/plugins/vite-plugin/discovery-runtime.ts","../src/plugins/vite-plugin/duplicate-registrations.test.ts","../src/plugins/vite-plugin/fixture-subpaths.test.ts","../src/plugins/vite-plugin/index.ts","../src/plugins/vite-plugin/lazy-services.test.ts","../src/plugins/vite-plugin/lifecycle-and-hmr.test.ts","../src/plugins/vite-plugin/manifest-utils.ts","../src/plugins/vite-plugin/manifest-utils.validation.test.ts","../src/plugins/vite-plugin/module-generation.test.ts","../src/plugins/vite-plugin/test-utils.ts","../src/plugins/vite-plugin/transform-guards.test.ts","../src/plugins/vite-plugin/visualization-option.test.ts","../src/plugins/vite-plugin/visualization-utils.ts","../src/plugins/vite-plugin/visualizer.test.ts","../src/plugins/vite-plugin/visualizer.ts"],"version":"6.0.3"}
1
+ {"root":["../src/entry-points.test.ts","../src/fixture-subpaths.test.ts","../src/rollup.ts","../src/runtime.ts","../src/test.ts","../src/vite.ts","../src/lib/container.identifiers.test.ts","../src/lib/container.internals.test.ts","../src/lib/container.test.ts","../src/lib/container.testing-features.test.ts","../src/lib/container.ts","../src/lib/decorators.runtime.test.ts","../src/lib/decorators.test-d.ts","../src/lib/decorators.ts","../src/lib/dependency-error.ts","../src/lib/env-detection.test.ts","../src/lib/env-detection.ts","../src/lib/lazy-retry.test.ts","../src/lib/lazy.ts","../src/lib/providers.test.ts","../src/lib/providers.ts","../src/lib/scope.ts","../src/lib/service-identifiers.test.ts","../src/lib/service-identifiers.ts","../src/lib/tokens.test.ts","../src/lib/types.ts","../src/lib/testing/mocking.test.ts","../src/lib/testing/mocking.ts","../src/lib/testing/registry.test.ts","../src/lib/testing/registry.ts","../src/plugins/core/codegen.test.ts","../src/plugins/core/codegen.ts","../src/plugins/core/container-loader.test.ts","../src/plugins/core/container-loader.ts","../src/plugins/core/decorators.helpers.test.ts","../src/plugins/core/decorators.ts","../src/plugins/core/discovery-runtime.ts","../src/plugins/core/discovery-store.test.ts","../src/plugins/core/discovery-store.ts","../src/plugins/core/identifier-resolver.test.ts","../src/plugins/core/identifier-resolver.ts","../src/plugins/core/lazy-utils.ts","../src/plugins/core/lazy.helpers.test.ts","../src/plugins/core/lazy.ts","../src/plugins/core/manifest-utils.ts","../src/plugins/core/manifest-utils.validation.test.ts","../src/plugins/core/scanner.test.ts","../src/plugins/core/scanner.ts","../src/plugins/core/types.ts","../src/plugins/core/utils.ts","../src/plugins/core/utils.walk.test.ts","../src/plugins/core/utils.write.test.ts","../src/plugins/core/visualization-utils.ts","../src/plugins/core/visualizer.test.ts","../src/plugins/core/visualizer.ts","../src/plugins/rollup-plugin/build-utils.ts","../src/plugins/rollup-plugin/index.ts","../src/plugins/rollup-plugin/rollup-plugin.test.ts","../src/plugins/vite-plugin/duplicate-registrations.test.ts","../src/plugins/vite-plugin/index.ts","../src/plugins/vite-plugin/lazy-services.test.ts","../src/plugins/vite-plugin/lifecycle-and-hmr.test.ts","../src/plugins/vite-plugin/module-generation.test.ts","../src/plugins/vite-plugin/module-invalidation.ts","../src/plugins/vite-plugin/test-utils.ts","../src/plugins/vite-plugin/transform-guards.test.ts","../src/plugins/vite-plugin/visualization-option.test.ts"],"version":"6.0.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alloy-di",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "A compile-time dependency injection plugin for Vite",
5
5
  "keywords": [
6
6
  "dependency-injection",