alloy-di 1.4.0 → 2.0.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/README.md +12 -7
- package/bin/alloy.js +9 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +168 -0
- package/dist/generate.d.ts +28 -0
- package/dist/generate.js +46 -0
- package/dist/lib/container.d.ts +78 -3
- package/dist/lib/container.js +130 -6
- package/dist/lib/lazy.d.ts +5 -1
- package/dist/lib/providers.d.ts +26 -2
- package/dist/lib/providers.js +23 -1
- package/dist/lib/scope.d.ts +21 -1
- package/dist/lib/types.d.ts +3 -1
- package/dist/plugins/consumer-plugin.d.ts +40 -0
- package/dist/plugins/consumer-plugin.js +101 -0
- package/dist/plugins/core/codegen.js +3 -3
- package/dist/plugins/core/container-loader.js +32 -17
- package/dist/plugins/core/discovery-runtime.js +19 -4
- package/dist/plugins/core/discovery-store.js +20 -6
- package/dist/plugins/core/generation-inputs.js +41 -0
- package/dist/plugins/core/scanner.js +71 -7
- package/dist/plugins/core/utils.js +1 -1
- package/dist/plugins/core/visualization-utils.js +1 -1
- package/dist/plugins/core/visualizer.js +40 -10
- package/dist/plugins/rollup-plugin/index.js +3 -5
- package/dist/plugins/vite-plugin/index.d.ts +2 -29
- package/dist/plugins/vite-plugin/index.js +19 -65
- package/dist/plugins/webpack-like-plugin.d.ts +32 -0
- package/dist/plugins/webpack-like-plugin.js +86 -0
- package/dist/rspack.d.ts +7 -0
- package/dist/rspack.js +10 -0
- package/dist/runtime.d.ts +6 -6
- package/dist/runtime.js +4 -4
- package/dist/scopes.d.ts +9 -1
- package/dist/scopes.js +29 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/vite.d.ts +2 -1
- package/dist/webpack.d.ts +7 -0
- package/dist/webpack.js +10 -0
- package/package.json +28 -12
- package/dist/lib/testing/mocking.d.ts +0 -12
- package/dist/lib/testing/mocking.js +0 -107
- package/dist/lib/testing/registry.js +0 -17
- package/dist/test.d.ts +0 -50
- package/dist/test.js +0 -65
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# alloy-di
|
|
2
2
|
|
|
3
|
-
`alloy-di` is a build-time dependency injection toolkit for
|
|
3
|
+
`alloy-di` is a build-time dependency injection toolkit for TypeScript apps. It scans your TypeScript during build, generates a static container, and ships a tiny runtime so you get dependency injection without reflection overhead.
|
|
4
4
|
|
|
5
5
|
## Highlights
|
|
6
6
|
|
|
7
7
|
- **Build-time graph** – services, scopes, and dependencies are resolved while bundling, so runtime work stays minimal.
|
|
8
|
-
- **Visualize your DI graph** – enable the
|
|
8
|
+
- **Visualize your DI graph** – enable the build plugin’s `visualize` option to emit a Mermaid diagram (`./alloy-di.mmd` by default) that captures scopes, lazy edges, and tokens for easy review.
|
|
9
9
|
- **First-class lazy loading** – use `Lazy()` or provider-based lazy registrations to keep optional features in separate chunks.
|
|
10
|
-
- **
|
|
10
|
+
- **Bundler support** – use the generated container from Vite, webpack, or Rspack applications.
|
|
11
11
|
- **Type safe** – generates `serviceIdentifiers` and manifest declarations for precise inference.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
@@ -51,13 +51,17 @@ pnpm add -D alloy-di
|
|
|
51
51
|
const app = await container.get(serviceIdentifiers.AppService);
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
> **Build tip:** The default Vite scaffold (`pnpm create vite@latest`) wires `"build": "tsc && vite build"`.
|
|
54
|
+
> **Build tip:** The default Vite scaffold (`pnpm create vite@latest`) wires `"build": "tsc && vite build"`. Run `alloy generate` before type-checking so fresh checkouts and CI have Alloy's ambient declarations available, for example `"build": "alloy generate && tsc && vite build"`.
|
|
55
|
+
|
|
56
|
+
By default Alloy scans `src` for decorated services. If your services live elsewhere, configure `sourceDirs` in the Vite plugin; `alloy generate` will reuse that config.
|
|
57
|
+
|
|
58
|
+
Webpack and Rspack apps can use `alloy-di/webpack` or `alloy-di/rspack` with the same options. Run `alloy generate --bundler webpack` or `alloy generate --bundler rspack` before type-checking.
|
|
55
59
|
|
|
56
60
|
Need manifests, providers, or testing utilities? See the docs site for complete guides.
|
|
57
61
|
|
|
58
62
|
## Visualize your dependency graph
|
|
59
63
|
|
|
60
|
-
Enable the
|
|
64
|
+
Enable the build plugin’s `visualize` option to have Alloy emit a Mermaid diagram that reflects every discovered service, scope, lazy edge, and token. By default the graph is written to `./alloy-di.mmd`, but you can customize the output path, color palette, or layout direction to fit your workflow.
|
|
61
65
|
|
|
62
66
|
```ts
|
|
63
67
|
import { defineConfig } from "vite";
|
|
@@ -90,8 +94,9 @@ The site covers getting started, plugin options, manifest authoring, lazy loadin
|
|
|
90
94
|
|
|
91
95
|
## Examples in this repo
|
|
92
96
|
|
|
93
|
-
- `packages/examples/app` – React + Vite app consuming decorated services, manifests, and providers.
|
|
97
|
+
- `packages/examples/app-vite` – React + Vite app consuming decorated services, manifests, and providers.
|
|
98
|
+
- `packages/examples/app-webpack-rspack` – the same React app source built with webpack and Rspack.
|
|
94
99
|
- `packages/examples/library-internal` – monorepo library that emits `alloy.manifest.mjs` via the Rolldown plugin.
|
|
95
100
|
- `packages/examples/library-external` – plain classes registered through providers.
|
|
96
101
|
|
|
97
|
-
Clone the repo, run `pnpm install`, then `pnpm --filter @alloy-di/example-app dev` to explore.
|
|
102
|
+
Clone the repo, run `pnpm install`, then `pnpm --filter @alloy-di/example-app-vite dev` to explore.
|
package/bin/alloy.js
ADDED
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AlloyPluginOptions } from "./plugins/consumer-plugin.js";
|
|
2
|
+
import { AlloyGenerateOptions, generate } from "./generate.js";
|
|
3
|
+
//#region src/cli.d.ts
|
|
4
|
+
type GenerateBundler = "vite" | "webpack" | "rspack" | "none";
|
|
5
|
+
interface GenerateCliOptions {
|
|
6
|
+
root?: string;
|
|
7
|
+
configFile?: string;
|
|
8
|
+
bundler?: GenerateBundler;
|
|
9
|
+
mode?: string;
|
|
10
|
+
help?: boolean;
|
|
11
|
+
}
|
|
12
|
+
interface RunCliDependencies {
|
|
13
|
+
generate: typeof generate;
|
|
14
|
+
resolveGenerateOptions: typeof resolveGenerateOptions;
|
|
15
|
+
log: (message: string) => void;
|
|
16
|
+
}
|
|
17
|
+
declare function printUsage(log?: (message: string) => void): void;
|
|
18
|
+
declare function readOptionValue(args: string[], index: number, optionName: string): string;
|
|
19
|
+
declare function parseGenerateArgs(args: string[]): GenerateCliOptions;
|
|
20
|
+
declare function getAlloyOptions(plugin: unknown): AlloyPluginOptions | undefined;
|
|
21
|
+
declare function resolveGenerateOptions(cliOptions: GenerateCliOptions): Promise<AlloyGenerateOptions>;
|
|
22
|
+
declare function runCli(args?: string[], deps?: RunCliDependencies): Promise<void>;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { GenerateBundler, GenerateCliOptions, RunCliDependencies, getAlloyOptions, parseGenerateArgs, printUsage, readOptionValue, resolveGenerateOptions, runCli };
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { generate } from "./generate.js";
|
|
3
|
+
import { ALLOY_PLUGIN_OPTIONS } from "./plugins/consumer-plugin.js";
|
|
4
|
+
import { ALLOY_VITE_PLUGIN_OPTIONS } from "./plugins/vite-plugin/index.js";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
//#region src/cli.ts
|
|
9
|
+
const defaultRunCliDependencies = {
|
|
10
|
+
generate,
|
|
11
|
+
resolveGenerateOptions,
|
|
12
|
+
log: (message) => console.log(message)
|
|
13
|
+
};
|
|
14
|
+
function printUsage(log = console.log) {
|
|
15
|
+
log(`Usage:
|
|
16
|
+
alloy generate [--root <dir>] [--config <file>] [--bundler <vite|webpack|rspack|none>] [--mode <mode>]
|
|
17
|
+
|
|
18
|
+
Commands:
|
|
19
|
+
generate Generate Alloy declaration files without bundling.
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--root Project root. Defaults to the current working directory.
|
|
23
|
+
--config Bundler config file to load. Pass "false" to skip config loading.
|
|
24
|
+
--bundler Config loader to use. Defaults to "vite" unless --config false is set.
|
|
25
|
+
--mode Mode passed to webpack/Rspack config functions. Defaults to "production".`);
|
|
26
|
+
}
|
|
27
|
+
function readOptionValue(args, index, optionName) {
|
|
28
|
+
const value = args[index + 1];
|
|
29
|
+
if (!value || value.startsWith("-")) throw new Error(`[alloy] ${optionName} requires a value.`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function parseGenerateArgs(args) {
|
|
33
|
+
const options = {};
|
|
34
|
+
for (let i = 0; i < args.length; i++) {
|
|
35
|
+
const arg = args[i];
|
|
36
|
+
if (arg === "--root" || arg === "-r") {
|
|
37
|
+
options.root = readOptionValue(args, i, arg);
|
|
38
|
+
i++;
|
|
39
|
+
} else if (arg === "--config" || arg === "-c") {
|
|
40
|
+
options.configFile = readOptionValue(args, i, arg);
|
|
41
|
+
i++;
|
|
42
|
+
} else if (arg === "--bundler" || arg === "-b") {
|
|
43
|
+
const bundler = readOptionValue(args, i, arg);
|
|
44
|
+
if (bundler !== "vite" && bundler !== "webpack" && bundler !== "rspack" && bundler !== "none") throw new Error(`[alloy] --bundler must be one of "vite", "webpack", "rspack", or "none".`);
|
|
45
|
+
options.bundler = bundler;
|
|
46
|
+
i++;
|
|
47
|
+
} else if (arg === "--mode" || arg === "-m") {
|
|
48
|
+
options.mode = readOptionValue(args, i, arg);
|
|
49
|
+
i++;
|
|
50
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
51
|
+
options.help = true;
|
|
52
|
+
return options;
|
|
53
|
+
} else throw new Error(`[alloy] Unknown option "${arg}".`);
|
|
54
|
+
}
|
|
55
|
+
return options;
|
|
56
|
+
}
|
|
57
|
+
function getAlloyOptions(plugin) {
|
|
58
|
+
if (!plugin || typeof plugin !== "object") return;
|
|
59
|
+
const candidate = plugin;
|
|
60
|
+
return candidate[ALLOY_PLUGIN_OPTIONS] ?? candidate[ALLOY_VITE_PLUGIN_OPTIONS];
|
|
61
|
+
}
|
|
62
|
+
async function resolveGenerateOptions(cliOptions) {
|
|
63
|
+
const root = cliOptions.root ? path.resolve(cliOptions.root) : process.cwd();
|
|
64
|
+
const bundler = resolveBundler(cliOptions);
|
|
65
|
+
if (bundler === "none") return { root };
|
|
66
|
+
if (bundler === "vite") return resolveViteGenerateOptions(cliOptions, root);
|
|
67
|
+
return resolveWebpackLikeGenerateOptions(cliOptions, root, bundler);
|
|
68
|
+
}
|
|
69
|
+
function resolveBundler(cliOptions) {
|
|
70
|
+
if (cliOptions.configFile === "false") return "none";
|
|
71
|
+
return cliOptions.bundler ?? "vite";
|
|
72
|
+
}
|
|
73
|
+
async function resolveViteGenerateOptions(cliOptions, root) {
|
|
74
|
+
let vite;
|
|
75
|
+
try {
|
|
76
|
+
vite = await import("vite");
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error("[alloy] Could not load Vite. Install vite or run with --config false and provide options programmatically.");
|
|
79
|
+
}
|
|
80
|
+
const inlineConfig = {
|
|
81
|
+
root,
|
|
82
|
+
configFile: cliOptions.configFile
|
|
83
|
+
};
|
|
84
|
+
const config = await vite.resolveConfig(inlineConfig, "build", "production", "production");
|
|
85
|
+
const alloyOptions = config.plugins.map((plugin) => getAlloyOptions(plugin)).find((options) => Boolean(options));
|
|
86
|
+
if (!alloyOptions) throw new Error("[alloy] Could not find alloy() in the resolved Vite plugins.");
|
|
87
|
+
return {
|
|
88
|
+
...alloyOptions,
|
|
89
|
+
root: config.root
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async function resolveWebpackLikeGenerateOptions(cliOptions, root, bundler) {
|
|
93
|
+
const alloyOptions = normalizeWebpackLikeConfigs(await evaluateWebpackLikeConfig(await loadBundlerConfig(resolveBundlerConfigFile(root, bundler, cliOptions.configFile), bundler), cliOptions.mode)).flatMap((config) => Array.isArray(config.plugins) ? config.plugins : []).map((plugin) => getAlloyOptions(plugin)).find((options) => Boolean(options));
|
|
94
|
+
if (!alloyOptions) throw new Error(`[alloy] Could not find alloy() in the resolved ${bundlerLabel(bundler)} plugins.`);
|
|
95
|
+
return {
|
|
96
|
+
...alloyOptions,
|
|
97
|
+
root
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function resolveBundlerConfigFile(root, bundler, configFile) {
|
|
101
|
+
if (configFile) return path.isAbsolute(configFile) ? configFile : path.resolve(root, configFile);
|
|
102
|
+
for (const extension of [
|
|
103
|
+
"js",
|
|
104
|
+
"mjs",
|
|
105
|
+
"cjs"
|
|
106
|
+
]) {
|
|
107
|
+
const candidate = path.resolve(root, `${bundler}.config.${extension}`);
|
|
108
|
+
try {
|
|
109
|
+
if (fsExists(candidate)) return candidate;
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`[alloy] Could not find ${bundlerLabel(bundler)} config. Pass --config <file> or --config false.`);
|
|
113
|
+
}
|
|
114
|
+
function fsExists(filePath) {
|
|
115
|
+
return fs.existsSync(filePath);
|
|
116
|
+
}
|
|
117
|
+
async function loadBundlerConfig(configFile, bundler) {
|
|
118
|
+
if (/\.[cm]?ts$/i.test(configFile)) throw new Error(`[alloy] ${bundlerLabel(bundler)} TypeScript config loading is not supported by alloy generate yet. Use a JavaScript config wrapper or pass --config false.`);
|
|
119
|
+
try {
|
|
120
|
+
const imported = await import(pathToFileURL(configFile).href);
|
|
121
|
+
return imported.default ?? imported;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new Error(`[alloy] Could not load ${bundlerLabel(bundler)} config at ${configFile}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function evaluateWebpackLikeConfig(config, mode = "production") {
|
|
127
|
+
if (typeof config !== "function") return config;
|
|
128
|
+
return config({
|
|
129
|
+
mode,
|
|
130
|
+
production: mode === "production"
|
|
131
|
+
}, {
|
|
132
|
+
mode,
|
|
133
|
+
env: { mode }
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function normalizeWebpackLikeConfigs(config) {
|
|
137
|
+
return (Array.isArray(config) ? config : [config]).filter((candidate) => Boolean(candidate) && typeof candidate === "object");
|
|
138
|
+
}
|
|
139
|
+
function bundlerLabel(bundler) {
|
|
140
|
+
return bundler === "rspack" ? "Rspack" : "webpack";
|
|
141
|
+
}
|
|
142
|
+
async function runCli(args = process.argv.slice(2), deps = defaultRunCliDependencies) {
|
|
143
|
+
const [command, ...commandArgs] = args;
|
|
144
|
+
if (!command || command === "--help" || command === "-h") {
|
|
145
|
+
printUsage(deps.log);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (command !== "generate") throw new Error(`[alloy] Unknown command "${command}".`);
|
|
149
|
+
const cliOptions = parseGenerateArgs(commandArgs);
|
|
150
|
+
if (cliOptions.help) {
|
|
151
|
+
printUsage(deps.log);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const options = await deps.resolveGenerateOptions(cliOptions);
|
|
155
|
+
const result = await deps.generate(options);
|
|
156
|
+
deps.log(`[alloy] Generated declarations for ${result.serviceCount} service(s) in ${result.declarationDir}.`);
|
|
157
|
+
}
|
|
158
|
+
function isMainModule() {
|
|
159
|
+
return process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
|
|
160
|
+
}
|
|
161
|
+
if (isMainModule()) try {
|
|
162
|
+
await runCli();
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
export { getAlloyOptions, parseGenerateArgs, printUsage, readOptionValue, resolveGenerateOptions, runCli };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { AlloyPluginOptions } from "./plugins/consumer-plugin.js";
|
|
2
|
+
|
|
3
|
+
//#region src/generate.d.ts
|
|
4
|
+
interface AlloyGenerateOptions extends AlloyPluginOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Project root to scan. Defaults to `process.cwd()`.
|
|
7
|
+
*/
|
|
8
|
+
root?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Override the package name used for generated service identifier keys.
|
|
11
|
+
* Defaults to the nearest `package.json` name under `root`.
|
|
12
|
+
*/
|
|
13
|
+
packageName?: string;
|
|
14
|
+
}
|
|
15
|
+
interface AlloyGenerateResult {
|
|
16
|
+
root: string;
|
|
17
|
+
declarationDir: string;
|
|
18
|
+
serviceCount: number;
|
|
19
|
+
manifestCount: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Generates Alloy's ambient TypeScript declarations without running a Vite
|
|
23
|
+
* build. This is intended for fresh checkouts and CI pipelines that type-check
|
|
24
|
+
* before bundling.
|
|
25
|
+
*/
|
|
26
|
+
declare function generate(options?: AlloyGenerateOptions): Promise<AlloyGenerateResult>;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { AlloyGenerateOptions, AlloyGenerateResult, generate };
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { normalizeImportPath } from "./plugins/core/utils.js";
|
|
2
|
+
import { prepareContainerData, writeDeclarationArtifacts } from "./plugins/core/container-loader.js";
|
|
3
|
+
import { DEFAULT_SOURCE_DIRS, createDiscoveryRuntimeForSourceDirs, readPackageName, toLazyServiceKey } from "./plugins/core/generation-inputs.js";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
//#region src/generate.ts
|
|
6
|
+
/**
|
|
7
|
+
* Generates Alloy's ambient TypeScript declarations without running a Vite
|
|
8
|
+
* build. This is intended for fresh checkouts and CI pipelines that type-check
|
|
9
|
+
* before bundling.
|
|
10
|
+
*/
|
|
11
|
+
async function generate(options = {}) {
|
|
12
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
13
|
+
const packageName = options.packageName ?? readPackageName(root);
|
|
14
|
+
const discoveryRuntime = createDiscoveryRuntimeForSourceDirs(root, options.sourceDirs ?? DEFAULT_SOURCE_DIRS, { factoryProviders: false });
|
|
15
|
+
const providerImportPaths = (options.providers ?? []).map((entry) => {
|
|
16
|
+
return normalizeImportPath(path.isAbsolute(entry) ? entry : path.resolve(root, entry));
|
|
17
|
+
});
|
|
18
|
+
const prepared = await prepareContainerData({
|
|
19
|
+
localMetas: Array.from(discoveryRuntime.discoveredClasses.values()),
|
|
20
|
+
lazyReferencedClassKeys: discoveryRuntime.lazyReferencedClassKeys,
|
|
21
|
+
manifests: options.manifests ?? [],
|
|
22
|
+
providerImportPaths,
|
|
23
|
+
factoryProviders: [],
|
|
24
|
+
lazyServiceKeys: new Set((options.lazyServices ?? []).map(toLazyServiceKey)),
|
|
25
|
+
packageName,
|
|
26
|
+
resolvedRoot: root,
|
|
27
|
+
containerDeclarationDir: options.containerDeclarationDir,
|
|
28
|
+
resolvedVisualization: null,
|
|
29
|
+
scopes: options.scopes
|
|
30
|
+
});
|
|
31
|
+
writeDeclarationArtifacts({
|
|
32
|
+
metas: prepared.metas,
|
|
33
|
+
loadedManifests: prepared.loadedManifests,
|
|
34
|
+
resolvedRoot: root,
|
|
35
|
+
containerDeclarationDir: options.containerDeclarationDir,
|
|
36
|
+
scopes: options.scopes
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
root,
|
|
40
|
+
declarationDir: path.resolve(root, options.containerDeclarationDir ?? "./src"),
|
|
41
|
+
serviceCount: prepared.metas.length,
|
|
42
|
+
manifestCount: prepared.loadedManifests.length
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
export { generate };
|
package/dist/lib/container.d.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { Constructor, Newable, Token } from "./types.js";
|
|
2
|
-
import { ResolutionContext, ServiceScope } from "./scope.js";
|
|
3
2
|
import { ServiceIdentifier } from "./service-identifiers.js";
|
|
3
|
+
import { FactoryCacheEntry, FactoryPendingEntry, ResolutionContext, ServiceScope } from "./scope.js";
|
|
4
4
|
|
|
5
5
|
//#region src/lib/container.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* The container-like surface a factory receives. Both the root `Container` and
|
|
8
|
+
* a `Scope` satisfy it, so a factory can resolve its own dependencies against
|
|
9
|
+
* whichever context owns its lifecycle (root for `singleton`, the scope for a
|
|
10
|
+
* custom-scoped factory).
|
|
11
|
+
*/
|
|
12
|
+
interface FactoryContext {
|
|
13
|
+
get<T>(target: Newable<T> | ServiceIdentifier<T>): Promise<T>;
|
|
14
|
+
getToken<T>(token: Token<T>): T;
|
|
15
|
+
}
|
|
16
|
+
/** A token-bound factory function executed at resolution time. */
|
|
17
|
+
type FactoryFn<T = unknown> = (context: FactoryContext) => T | Promise<T>;
|
|
6
18
|
/**
|
|
7
19
|
* Runtime dependency injection container used by generated modules and tests.
|
|
8
20
|
*
|
|
@@ -15,17 +27,27 @@ declare class Container implements ResolutionContext {
|
|
|
15
27
|
private readonly instanceOverrides;
|
|
16
28
|
private readonly metadataCache;
|
|
17
29
|
private readonly valueProviders;
|
|
30
|
+
private readonly factoryRegistry;
|
|
31
|
+
private readonly factoryValues;
|
|
32
|
+
private readonly factoryPending;
|
|
33
|
+
private factoryGeneration;
|
|
18
34
|
private readonly factoryWarningCache;
|
|
19
35
|
private readonly scopeHierarchy;
|
|
20
36
|
readonly scopeName: ServiceScope;
|
|
21
37
|
readonly parent: ResolutionContext | null;
|
|
22
38
|
getCached(target: Constructor): unknown;
|
|
39
|
+
hasCached(target: Constructor): boolean;
|
|
23
40
|
setCached(target: Constructor, instance: unknown): void;
|
|
24
41
|
getPending(target: Constructor): Promise<unknown> | undefined;
|
|
25
42
|
setPending(target: Constructor, promise: Promise<unknown>): void;
|
|
26
43
|
deletePending(target: Constructor): void;
|
|
27
44
|
getProvider(tokenId: symbol): unknown;
|
|
28
45
|
hasProvider(tokenId: symbol): boolean;
|
|
46
|
+
getFactoryValue(tokenId: symbol): FactoryCacheEntry | undefined;
|
|
47
|
+
setFactoryValue(tokenId: symbol, generation: number, value: unknown): void;
|
|
48
|
+
getFactoryPending(tokenId: symbol): FactoryPendingEntry | undefined;
|
|
49
|
+
setFactoryPending(tokenId: symbol, generation: number, promise: Promise<unknown>): void;
|
|
50
|
+
deleteFactoryPending(tokenId: symbol, generation: number): void;
|
|
29
51
|
/**
|
|
30
52
|
* Resolve (and construct) the requested service.
|
|
31
53
|
*
|
|
@@ -64,6 +86,23 @@ declare class Container implements ResolutionContext {
|
|
|
64
86
|
* @param value - The value that should be injected when the token is requested.
|
|
65
87
|
*/
|
|
66
88
|
provideValue<T>(token: Token<T>, value: T): void;
|
|
89
|
+
/**
|
|
90
|
+
* Register a factory function for an injection token. The factory runs at
|
|
91
|
+
* resolution time and receives the container so it can resolve its own
|
|
92
|
+
* dependencies.
|
|
93
|
+
*
|
|
94
|
+
* This is the imperative escape hatch backing the declarative `asFactory`
|
|
95
|
+
* provider; prefer `asFactory` + `applyProviders` for static registration.
|
|
96
|
+
*
|
|
97
|
+
* @param token - The token created via `createToken`.
|
|
98
|
+
* @param fn - Factory invoked with the resolving context; may be async.
|
|
99
|
+
* @param options.lifecycle - `singleton` (default, cached on the root),
|
|
100
|
+
* `transient` (re-run on every resolution), or a custom scope name (cached
|
|
101
|
+
* once per matching scope instance and disposed with it).
|
|
102
|
+
*/
|
|
103
|
+
provideFactory<T>(token: Token<T>, fn: FactoryFn<T>, options?: {
|
|
104
|
+
lifecycle?: ServiceScope;
|
|
105
|
+
}): void;
|
|
67
106
|
/**
|
|
68
107
|
* Retrieve a provided value for a token from this container.
|
|
69
108
|
* Throws if no provider is registered for the token.
|
|
@@ -125,9 +164,45 @@ declare class Container implements ResolutionContext {
|
|
|
125
164
|
*/
|
|
126
165
|
private runImporterWithRetry;
|
|
127
166
|
/**
|
|
128
|
-
* Resolve a token dependency via registered value providers.
|
|
167
|
+
* Resolve a token dependency via registered value providers or factories.
|
|
129
168
|
*/
|
|
130
169
|
private resolveTokenLike;
|
|
170
|
+
/**
|
|
171
|
+
* Execute a token-bound factory according to its lifecycle, caching its
|
|
172
|
+
* result on the context that owns that lifecycle:
|
|
173
|
+
*
|
|
174
|
+
* - `transient`: run `fn` on every call against the current context; nothing
|
|
175
|
+
* is cached.
|
|
176
|
+
* - `singleton`: cache on the root container.
|
|
177
|
+
* - custom scope: cache on the nearest ancestor context whose `scopeName`
|
|
178
|
+
* matches; if none is active, fall back to transient behaviour (mirrors how
|
|
179
|
+
* class resolution treats a custom scope with no matching context).
|
|
180
|
+
*/
|
|
181
|
+
private resolveFactory;
|
|
182
|
+
/**
|
|
183
|
+
* Resolve a factory whose result is cached on `targetCtx`, keyed by token id.
|
|
184
|
+
* Mirrors `resolveCached` for classes: return the cached value, else coalesce
|
|
185
|
+
* concurrent first-resolutions onto a single `pending` promise, else run the
|
|
186
|
+
* factory and cache. `pending` is cleared in `finally` so a rejected factory
|
|
187
|
+
* can be retried rather than caching the failure.
|
|
188
|
+
*/
|
|
189
|
+
private resolveFactoryCached;
|
|
190
|
+
/**
|
|
191
|
+
* Invoke a factory's function against the context that owns its lifecycle,
|
|
192
|
+
* with a best-effort synchronous re-entrancy guard.
|
|
193
|
+
*
|
|
194
|
+
* `executing` is set only for the synchronous duration of `fn(...)` — long
|
|
195
|
+
* enough to catch a factory that resolves its own token before returning (a
|
|
196
|
+
* synchronous self-cycle, or a synchronous mutual cycle between factories),
|
|
197
|
+
* but cleared the moment `fn` returns so that concurrent resolutions which
|
|
198
|
+
* legitimately coalesce on an in-flight result are never mistaken for a cycle.
|
|
199
|
+
*
|
|
200
|
+
* Known limitation: a factory that re-enters its own token *after* awaiting
|
|
201
|
+
* (an async cross-await self-reference) is indistinguishable from valid
|
|
202
|
+
* coalescing without async-context tracking, so that case is not caught here
|
|
203
|
+
* — it is documented in the factory-providers guide.
|
|
204
|
+
*/
|
|
205
|
+
private runFactory;
|
|
131
206
|
/**
|
|
132
207
|
* Format a readable representation of the resolution stack for error messages.
|
|
133
208
|
*/
|
|
@@ -138,4 +213,4 @@ declare class Container implements ResolutionContext {
|
|
|
138
213
|
private getServiceMetadata;
|
|
139
214
|
}
|
|
140
215
|
//#endregion
|
|
141
|
-
export { Container };
|
|
216
|
+
export { Container, FactoryFn };
|
package/dist/lib/container.js
CHANGED
|
@@ -42,6 +42,10 @@ var Container = class {
|
|
|
42
42
|
instanceOverrides = /* @__PURE__ */ new Map();
|
|
43
43
|
metadataCache = /* @__PURE__ */ new Map();
|
|
44
44
|
valueProviders = /* @__PURE__ */ new Map();
|
|
45
|
+
factoryRegistry = /* @__PURE__ */ new Map();
|
|
46
|
+
factoryValues = /* @__PURE__ */ new Map();
|
|
47
|
+
factoryPending = /* @__PURE__ */ new Map();
|
|
48
|
+
factoryGeneration = 0;
|
|
45
49
|
factoryWarningCache = /* @__PURE__ */ new WeakSet();
|
|
46
50
|
scopeHierarchy = /* @__PURE__ */ new Map();
|
|
47
51
|
scopeName = ServiceScope.SINGLETON;
|
|
@@ -49,6 +53,9 @@ var Container = class {
|
|
|
49
53
|
getCached(target) {
|
|
50
54
|
return this.singletons.get(target);
|
|
51
55
|
}
|
|
56
|
+
hasCached(target) {
|
|
57
|
+
return this.singletons.has(target);
|
|
58
|
+
}
|
|
52
59
|
setCached(target, instance) {
|
|
53
60
|
this.singletons.set(target, instance);
|
|
54
61
|
}
|
|
@@ -67,6 +74,27 @@ var Container = class {
|
|
|
67
74
|
hasProvider(tokenId) {
|
|
68
75
|
return this.valueProviders.has(tokenId);
|
|
69
76
|
}
|
|
77
|
+
getFactoryValue(tokenId) {
|
|
78
|
+
return this.factoryValues.get(tokenId);
|
|
79
|
+
}
|
|
80
|
+
setFactoryValue(tokenId, generation, value) {
|
|
81
|
+
this.factoryValues.set(tokenId, {
|
|
82
|
+
generation,
|
|
83
|
+
value
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
getFactoryPending(tokenId) {
|
|
87
|
+
return this.factoryPending.get(tokenId);
|
|
88
|
+
}
|
|
89
|
+
setFactoryPending(tokenId, generation, promise) {
|
|
90
|
+
this.factoryPending.set(tokenId, {
|
|
91
|
+
generation,
|
|
92
|
+
promise
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
deleteFactoryPending(tokenId, generation) {
|
|
96
|
+
if (this.factoryPending.get(tokenId)?.generation === generation) this.factoryPending.delete(tokenId);
|
|
97
|
+
}
|
|
70
98
|
async get(targetOrIdentifier) {
|
|
71
99
|
if (typeof targetOrIdentifier === "symbol") return this.getByIdentifier(targetOrIdentifier);
|
|
72
100
|
return this.getByConstructor(targetOrIdentifier, this);
|
|
@@ -123,11 +151,38 @@ var Container = class {
|
|
|
123
151
|
this.valueProviders.set(token.id, value);
|
|
124
152
|
}
|
|
125
153
|
/**
|
|
154
|
+
* Register a factory function for an injection token. The factory runs at
|
|
155
|
+
* resolution time and receives the container so it can resolve its own
|
|
156
|
+
* dependencies.
|
|
157
|
+
*
|
|
158
|
+
* This is the imperative escape hatch backing the declarative `asFactory`
|
|
159
|
+
* provider; prefer `asFactory` + `applyProviders` for static registration.
|
|
160
|
+
*
|
|
161
|
+
* @param token - The token created via `createToken`.
|
|
162
|
+
* @param fn - Factory invoked with the resolving context; may be async.
|
|
163
|
+
* @param options.lifecycle - `singleton` (default, cached on the root),
|
|
164
|
+
* `transient` (re-run on every resolution), or a custom scope name (cached
|
|
165
|
+
* once per matching scope instance and disposed with it).
|
|
166
|
+
*/
|
|
167
|
+
provideFactory(token, fn, options) {
|
|
168
|
+
this.factoryRegistry.set(token.id, {
|
|
169
|
+
fn,
|
|
170
|
+
lifecycle: options?.lifecycle ?? ServiceScope.SINGLETON,
|
|
171
|
+
description: token.description,
|
|
172
|
+
generation: ++this.factoryGeneration,
|
|
173
|
+
executing: false
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
126
177
|
* Retrieve a provided value for a token from this container.
|
|
127
178
|
* Throws if no provider is registered for the token.
|
|
128
179
|
*/
|
|
129
180
|
getToken(token) {
|
|
130
|
-
if (!this.valueProviders.has(token.id))
|
|
181
|
+
if (!this.valueProviders.has(token.id)) {
|
|
182
|
+
const label = token.description ?? String(token.id);
|
|
183
|
+
if (this.factoryRegistry.has(token.id)) throw new Error(`Token ${label} is registered as a factory provider, which cannot be retrieved synchronously via getToken(). Declare it as a dependency so it resolves through the async resolution path instead.`);
|
|
184
|
+
throw new Error(`No provider registered for token ${label}`);
|
|
185
|
+
}
|
|
131
186
|
return this.valueProviders.get(token.id);
|
|
132
187
|
}
|
|
133
188
|
async getByConstructor(target, context, options) {
|
|
@@ -150,8 +205,7 @@ var Container = class {
|
|
|
150
205
|
* @throws Error if a circular dependency is detected
|
|
151
206
|
*/
|
|
152
207
|
async resolve(target, resolutionStack, context) {
|
|
153
|
-
|
|
154
|
-
if (overridden) return overridden;
|
|
208
|
+
if (this.instanceOverrides.has(target)) return this.instanceOverrides.get(target);
|
|
155
209
|
if (resolutionStack.includes(target)) throw new DependencyResolutionError(`Circular dependency detected: ${[...resolutionStack.map((t) => t.name), target.name].join(" -> ")}`, {
|
|
156
210
|
target,
|
|
157
211
|
resolutionStack,
|
|
@@ -180,8 +234,7 @@ var Container = class {
|
|
|
180
234
|
return startingContext;
|
|
181
235
|
}
|
|
182
236
|
async resolveCached(target, metadata, resolutionStack, targetCtx) {
|
|
183
|
-
|
|
184
|
-
if (cached) return cached;
|
|
237
|
+
if (targetCtx.hasCached(target)) return targetCtx.getCached(target);
|
|
185
238
|
const pending = targetCtx.getPending(target);
|
|
186
239
|
if (pending) return await pending;
|
|
187
240
|
const creation = this.createInstance(target, metadata.dependencies, resolutionStack, metadata.factory, targetCtx).then((instance) => {
|
|
@@ -292,7 +345,7 @@ var Container = class {
|
|
|
292
345
|
}
|
|
293
346
|
}
|
|
294
347
|
/**
|
|
295
|
-
* Resolve a token dependency via registered value providers.
|
|
348
|
+
* Resolve a token dependency via registered value providers or factories.
|
|
296
349
|
*/
|
|
297
350
|
resolveTokenLike(tok, target, resolutionStack, context) {
|
|
298
351
|
let current = context;
|
|
@@ -300,6 +353,8 @@ var Container = class {
|
|
|
300
353
|
if (current.hasProvider(tok.id)) return current.getProvider(tok.id);
|
|
301
354
|
current = current.parent;
|
|
302
355
|
}
|
|
356
|
+
const factory = this.factoryRegistry.get(tok.id);
|
|
357
|
+
if (factory) return this.resolveFactory(tok.id, factory, context);
|
|
303
358
|
const stackPath = this.formatStackPath(target, resolutionStack);
|
|
304
359
|
throw new DependencyResolutionError(`No provider registered for token ${tok.description ?? String(tok.id)} while resolving ${target.name}. Resolution stack: ${stackPath}`, {
|
|
305
360
|
target,
|
|
@@ -308,6 +363,75 @@ var Container = class {
|
|
|
308
363
|
});
|
|
309
364
|
}
|
|
310
365
|
/**
|
|
366
|
+
* Execute a token-bound factory according to its lifecycle, caching its
|
|
367
|
+
* result on the context that owns that lifecycle:
|
|
368
|
+
*
|
|
369
|
+
* - `transient`: run `fn` on every call against the current context; nothing
|
|
370
|
+
* is cached.
|
|
371
|
+
* - `singleton`: cache on the root container.
|
|
372
|
+
* - custom scope: cache on the nearest ancestor context whose `scopeName`
|
|
373
|
+
* matches; if none is active, fall back to transient behaviour (mirrors how
|
|
374
|
+
* class resolution treats a custom scope with no matching context).
|
|
375
|
+
*/
|
|
376
|
+
resolveFactory(tokenId, descriptor, context) {
|
|
377
|
+
if (descriptor.lifecycle === ServiceScope.TRANSIENT) return this.runFactory(descriptor, context);
|
|
378
|
+
const targetCtx = this.findContextForScope(descriptor.lifecycle, context);
|
|
379
|
+
if (descriptor.lifecycle !== ServiceScope.SINGLETON && targetCtx.scopeName !== descriptor.lifecycle) return this.runFactory(descriptor, context);
|
|
380
|
+
return this.resolveFactoryCached(tokenId, descriptor, targetCtx);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Resolve a factory whose result is cached on `targetCtx`, keyed by token id.
|
|
384
|
+
* Mirrors `resolveCached` for classes: return the cached value, else coalesce
|
|
385
|
+
* concurrent first-resolutions onto a single `pending` promise, else run the
|
|
386
|
+
* factory and cache. `pending` is cleared in `finally` so a rejected factory
|
|
387
|
+
* can be retried rather than caching the failure.
|
|
388
|
+
*/
|
|
389
|
+
resolveFactoryCached(tokenId, descriptor, targetCtx) {
|
|
390
|
+
const generation = descriptor.generation;
|
|
391
|
+
const cached = targetCtx.getFactoryValue(tokenId);
|
|
392
|
+
if (cached?.generation === generation) return cached.value;
|
|
393
|
+
const pending = targetCtx.getFactoryPending(tokenId);
|
|
394
|
+
if (pending?.generation === generation) return pending.promise;
|
|
395
|
+
const creation = (async () => {
|
|
396
|
+
try {
|
|
397
|
+
const value = await this.runFactory(descriptor, targetCtx);
|
|
398
|
+
targetCtx.setFactoryValue(tokenId, generation, value);
|
|
399
|
+
return value;
|
|
400
|
+
} finally {
|
|
401
|
+
targetCtx.deleteFactoryPending(tokenId, generation);
|
|
402
|
+
}
|
|
403
|
+
})();
|
|
404
|
+
targetCtx.setFactoryPending(tokenId, generation, creation);
|
|
405
|
+
return creation;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Invoke a factory's function against the context that owns its lifecycle,
|
|
409
|
+
* with a best-effort synchronous re-entrancy guard.
|
|
410
|
+
*
|
|
411
|
+
* `executing` is set only for the synchronous duration of `fn(...)` — long
|
|
412
|
+
* enough to catch a factory that resolves its own token before returning (a
|
|
413
|
+
* synchronous self-cycle, or a synchronous mutual cycle between factories),
|
|
414
|
+
* but cleared the moment `fn` returns so that concurrent resolutions which
|
|
415
|
+
* legitimately coalesce on an in-flight result are never mistaken for a cycle.
|
|
416
|
+
*
|
|
417
|
+
* Known limitation: a factory that re-enters its own token *after* awaiting
|
|
418
|
+
* (an async cross-await self-reference) is indistinguishable from valid
|
|
419
|
+
* coalescing without async-context tracking, so that case is not caught here
|
|
420
|
+
* — it is documented in the factory-providers guide.
|
|
421
|
+
*/
|
|
422
|
+
runFactory(descriptor, context) {
|
|
423
|
+
if (descriptor.executing) {
|
|
424
|
+
const label = descriptor.description ?? "<token>";
|
|
425
|
+
throw new Error(`Circular factory dependency detected: the factory for token ${label} resolved its own token while it was still being constructed. Break the cycle by restructuring the factory or resolving the dependency lazily.`);
|
|
426
|
+
}
|
|
427
|
+
descriptor.executing = true;
|
|
428
|
+
try {
|
|
429
|
+
return descriptor.fn(context);
|
|
430
|
+
} finally {
|
|
431
|
+
descriptor.executing = false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
311
435
|
* Format a readable representation of the resolution stack for error messages.
|
|
312
436
|
*/
|
|
313
437
|
formatStackPath(target, resolutionStack) {
|
package/dist/lib/lazy.d.ts
CHANGED
|
@@ -14,6 +14,10 @@ interface Lazy<T> {
|
|
|
14
14
|
factor?: number;
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Narrow an unknown value to a `Lazy` wrapper based on the hidden identifier symbol.
|
|
19
|
+
*/
|
|
20
|
+
declare function isLazy(value: unknown): value is Lazy<unknown>;
|
|
17
21
|
/**
|
|
18
22
|
* Helper type to extract the instance type from a Newable constructor.
|
|
19
23
|
*/
|
|
@@ -36,4 +40,4 @@ declare function Lazy<F extends () => Promise<Newable<any> | {
|
|
|
36
40
|
}>>(importer: F, retry?: Lazy<any>["retry"]): Lazy<InferLazyType<F>>;
|
|
37
41
|
declare function Lazy<T>(importer: Importer<T>, retry?: Lazy<T>["retry"]): Lazy<T>;
|
|
38
42
|
//#endregion
|
|
39
|
-
export { LAZY_IDENTIFIER, Lazy };
|
|
43
|
+
export { LAZY_IDENTIFIER, Lazy, isLazy };
|