@typed/virtual-modules 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -0
- package/dist/CompilerHostAdapter.d.ts +3 -0
- package/dist/CompilerHostAdapter.d.ts.map +1 -0
- package/dist/CompilerHostAdapter.js +160 -0
- package/dist/LanguageServiceAdapter.d.ts +3 -0
- package/dist/LanguageServiceAdapter.d.ts.map +1 -0
- package/dist/LanguageServiceAdapter.js +488 -0
- package/dist/LanguageServiceSession.d.ts +16 -0
- package/dist/LanguageServiceSession.d.ts.map +1 -0
- package/dist/LanguageServiceSession.js +122 -0
- package/dist/NodeModulePluginLoader.d.ts +8 -0
- package/dist/NodeModulePluginLoader.d.ts.map +1 -0
- package/dist/NodeModulePluginLoader.js +175 -0
- package/dist/PluginManager.d.ts +10 -0
- package/dist/PluginManager.d.ts.map +1 -0
- package/dist/PluginManager.js +151 -0
- package/dist/TypeInfoApi.d.ts +71 -0
- package/dist/TypeInfoApi.d.ts.map +1 -0
- package/dist/TypeInfoApi.js +855 -0
- package/dist/VmcConfigLoader.d.ts +31 -0
- package/dist/VmcConfigLoader.d.ts.map +1 -0
- package/dist/VmcConfigLoader.js +231 -0
- package/dist/VmcResolverLoader.d.ts +30 -0
- package/dist/VmcResolverLoader.d.ts.map +1 -0
- package/dist/VmcResolverLoader.js +71 -0
- package/dist/collectTypeTargetSpecs.d.ts +6 -0
- package/dist/collectTypeTargetSpecs.d.ts.map +1 -0
- package/dist/collectTypeTargetSpecs.js +19 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/internal/VirtualRecordStore.d.ts +60 -0
- package/dist/internal/VirtualRecordStore.d.ts.map +1 -0
- package/dist/internal/VirtualRecordStore.js +199 -0
- package/dist/internal/materializeVirtualFile.d.ts +12 -0
- package/dist/internal/materializeVirtualFile.d.ts.map +1 -0
- package/dist/internal/materializeVirtualFile.js +28 -0
- package/dist/internal/path.d.ts +40 -0
- package/dist/internal/path.d.ts.map +1 -0
- package/dist/internal/path.js +71 -0
- package/dist/internal/sanitize.d.ts +6 -0
- package/dist/internal/sanitize.d.ts.map +1 -0
- package/dist/internal/sanitize.js +15 -0
- package/dist/internal/tsInternal.d.ts +65 -0
- package/dist/internal/tsInternal.d.ts.map +1 -0
- package/dist/internal/tsInternal.js +99 -0
- package/dist/internal/validation.d.ts +28 -0
- package/dist/internal/validation.d.ts.map +1 -0
- package/dist/internal/validation.js +66 -0
- package/dist/typeTargetBootstrap.d.ts +33 -0
- package/dist/typeTargetBootstrap.d.ts.map +1 -0
- package/dist/typeTargetBootstrap.js +57 -0
- package/dist/types.d.ts +405 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/package.json +38 -0
- package/src/CompilerHostAdapter.test.ts +180 -0
- package/src/CompilerHostAdapter.ts +316 -0
- package/src/LanguageServiceAdapter.test.ts +521 -0
- package/src/LanguageServiceAdapter.ts +631 -0
- package/src/LanguageServiceSession.ts +160 -0
- package/src/LanguageServiceTester.integration.test.ts +268 -0
- package/src/NodeModulePluginLoader.test.ts +170 -0
- package/src/NodeModulePluginLoader.ts +268 -0
- package/src/PluginManager.test.ts +178 -0
- package/src/PluginManager.ts +218 -0
- package/src/TypeInfoApi.test.ts +1211 -0
- package/src/TypeInfoApi.ts +1228 -0
- package/src/VmcConfigLoader.test.ts +108 -0
- package/src/VmcConfigLoader.ts +297 -0
- package/src/VmcResolverLoader.test.ts +181 -0
- package/src/VmcResolverLoader.ts +116 -0
- package/src/collectTypeTargetSpecs.ts +22 -0
- package/src/index.ts +35 -0
- package/src/internal/VirtualRecordStore.ts +304 -0
- package/src/internal/materializeVirtualFile.ts +38 -0
- package/src/internal/path.ts +106 -0
- package/src/internal/sanitize.ts +16 -0
- package/src/internal/tsInternal.ts +127 -0
- package/src/internal/validation.ts +85 -0
- package/src/typeTargetBootstrap.ts +75 -0
- package/src/types.ts +535 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { loadVmcConfig } from "./VmcConfigLoader.js";
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
function createTempDir(): string {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "typed-vmc-config-"));
|
|
12
|
+
tempDirs.push(dir);
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
while (tempDirs.length > 0) {
|
|
18
|
+
const dir = tempDirs.pop();
|
|
19
|
+
if (dir) {
|
|
20
|
+
try {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore cleanup failures in test temp dirs
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("loadVmcConfig", () => {
|
|
30
|
+
it("returns not-found when vmc config does not exist", () => {
|
|
31
|
+
const dir = createTempDir();
|
|
32
|
+
const result = loadVmcConfig({ projectRoot: dir, ts });
|
|
33
|
+
expect(result.status).toBe("not-found");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("loads vmc.config.ts with default export", () => {
|
|
37
|
+
const dir = createTempDir();
|
|
38
|
+
writeFileSync(
|
|
39
|
+
join(dir, "vmc.config.ts"),
|
|
40
|
+
`const plugin = {
|
|
41
|
+
name: "virtual",
|
|
42
|
+
shouldResolve: (id) => id === "virtual:foo",
|
|
43
|
+
build: () => "export const value = 1;",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default {
|
|
47
|
+
plugins: [plugin, "./plugin.mjs"],
|
|
48
|
+
};
|
|
49
|
+
`,
|
|
50
|
+
"utf8",
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const result = loadVmcConfig({ projectRoot: dir, ts });
|
|
54
|
+
expect(result.status).toBe("loaded");
|
|
55
|
+
if (result.status !== "loaded") return;
|
|
56
|
+
expect(result.path.endsWith("vmc.config.ts")).toBe(true);
|
|
57
|
+
expect(result.config.plugins).toBeDefined();
|
|
58
|
+
expect(result.config.plugins).toHaveLength(2);
|
|
59
|
+
const first = result.config.plugins?.[0];
|
|
60
|
+
const second = result.config.plugins?.[1];
|
|
61
|
+
expect(typeof first).toBe("object");
|
|
62
|
+
expect(typeof second).toBe("string");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns error when configPath is not a string", () => {
|
|
66
|
+
const dir = createTempDir();
|
|
67
|
+
const result = loadVmcConfig({
|
|
68
|
+
projectRoot: dir,
|
|
69
|
+
ts,
|
|
70
|
+
configPath: 123 as unknown as string,
|
|
71
|
+
});
|
|
72
|
+
expect(result.status).toBe("error");
|
|
73
|
+
if (result.status === "error") {
|
|
74
|
+
expect(result.message).toContain("configPath must be a string");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns error when configPath is not a .ts file", () => {
|
|
79
|
+
const dir = createTempDir();
|
|
80
|
+
writeFileSync(join(dir, "vmc.config.mjs"), `export default { plugins: [] };`, "utf8");
|
|
81
|
+
const result = loadVmcConfig({
|
|
82
|
+
projectRoot: dir,
|
|
83
|
+
ts,
|
|
84
|
+
configPath: "./vmc.config.mjs",
|
|
85
|
+
});
|
|
86
|
+
expect(result.status).toBe("error");
|
|
87
|
+
if (result.status === "error") {
|
|
88
|
+
expect(result.message).toContain(".ts");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns error when configPath is a directory", () => {
|
|
93
|
+
const dir = createTempDir();
|
|
94
|
+
const configDir = join(dir, "vmc.config.ts");
|
|
95
|
+
mkdirSync(configDir, { recursive: true });
|
|
96
|
+
const result = loadVmcConfig({ projectRoot: dir, ts });
|
|
97
|
+
expect(result.status).toBe("not-found");
|
|
98
|
+
const explicit = loadVmcConfig({
|
|
99
|
+
projectRoot: dir,
|
|
100
|
+
ts,
|
|
101
|
+
configPath: "./vmc.config.ts",
|
|
102
|
+
});
|
|
103
|
+
expect(explicit.status).toBe("error");
|
|
104
|
+
if (explicit.status === "error") {
|
|
105
|
+
expect(explicit.message).toContain("must point to a file");
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { runInThisContext } from "node:vm";
|
|
5
|
+
import { pathIsUnderBase, resolvePathUnderBase } from "./internal/path.js";
|
|
6
|
+
import { sanitizeErrorMessage } from "./internal/sanitize.js";
|
|
7
|
+
import { validatePathSegment } from "./internal/validation.js";
|
|
8
|
+
import type { VirtualModulePlugin, VirtualModuleResolver } from "./types.js";
|
|
9
|
+
|
|
10
|
+
const VMC_CONFIG_NAMES = ["vmc.config.ts"] as const;
|
|
11
|
+
|
|
12
|
+
export type VmcPluginEntry = VirtualModulePlugin | string;
|
|
13
|
+
|
|
14
|
+
export interface VmcConfig {
|
|
15
|
+
readonly resolver?: VirtualModuleResolver;
|
|
16
|
+
readonly plugins?: readonly VmcPluginEntry[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LoadVmcConfigOptions {
|
|
20
|
+
readonly projectRoot: string;
|
|
21
|
+
/**
|
|
22
|
+
* TypeScript module used to transpile vmc.config.ts on the fly.
|
|
23
|
+
* Required only when loading .ts configs.
|
|
24
|
+
*/
|
|
25
|
+
readonly ts?: typeof import("typescript");
|
|
26
|
+
/**
|
|
27
|
+
* Optional explicit config file path (absolute or relative to projectRoot).
|
|
28
|
+
*/
|
|
29
|
+
readonly configPath?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type LoadVmcConfigResult =
|
|
33
|
+
| { readonly status: "not-found" }
|
|
34
|
+
| { readonly status: "loaded"; readonly path: string; readonly config: VmcConfig }
|
|
35
|
+
| { readonly status: "error"; readonly path?: string; readonly message: string };
|
|
36
|
+
|
|
37
|
+
const toMessage = (error: unknown): string => {
|
|
38
|
+
if (error instanceof Error) return error.message;
|
|
39
|
+
return String(error);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const isPluginLike = (value: unknown): value is VirtualModulePlugin => {
|
|
43
|
+
if (!value || typeof value !== "object") return false;
|
|
44
|
+
const candidate = value as Record<string, unknown>;
|
|
45
|
+
return (
|
|
46
|
+
typeof candidate.name === "string" &&
|
|
47
|
+
typeof candidate.shouldResolve === "function" &&
|
|
48
|
+
typeof candidate.build === "function"
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isResolverLike = (value: unknown): value is VirtualModuleResolver => {
|
|
53
|
+
if (!value || typeof value !== "object") return false;
|
|
54
|
+
return typeof (value as { resolveModule?: unknown }).resolveModule === "function";
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const normalizePluginEntries = (
|
|
58
|
+
value: unknown,
|
|
59
|
+
): { readonly ok: true; readonly plugins: readonly VmcPluginEntry[] } | { readonly ok: false } => {
|
|
60
|
+
if (value === undefined) return { ok: true, plugins: [] };
|
|
61
|
+
if (!Array.isArray(value)) return { ok: false };
|
|
62
|
+
|
|
63
|
+
const plugins: VmcPluginEntry[] = [];
|
|
64
|
+
for (const entry of value) {
|
|
65
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
66
|
+
plugins.push(entry.trim());
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (isPluginLike(entry)) {
|
|
70
|
+
plugins.push(entry);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
return { ok: false };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, plugins };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function validateProjectRoot(projectRoot: unknown): LoadVmcConfigResult | string {
|
|
79
|
+
const projectRootResult = validatePathSegment(projectRoot, "projectRoot");
|
|
80
|
+
if (!projectRootResult.ok) {
|
|
81
|
+
return {
|
|
82
|
+
status: "error",
|
|
83
|
+
message: projectRootResult.reason,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const resolvedProjectRoot = resolve(projectRootResult.value);
|
|
87
|
+
if (!existsSync(resolvedProjectRoot)) {
|
|
88
|
+
return {
|
|
89
|
+
status: "error",
|
|
90
|
+
path: resolvedProjectRoot,
|
|
91
|
+
message: `projectRoot does not exist: ${resolvedProjectRoot}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (!statSync(resolvedProjectRoot).isDirectory()) {
|
|
95
|
+
return {
|
|
96
|
+
status: "error",
|
|
97
|
+
path: resolvedProjectRoot,
|
|
98
|
+
message: `projectRoot must be a directory: ${resolvedProjectRoot}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return resolvedProjectRoot;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveConfigPath(
|
|
105
|
+
projectRoot: string,
|
|
106
|
+
configPath?: unknown,
|
|
107
|
+
): LoadVmcConfigResult | string {
|
|
108
|
+
if (configPath !== undefined) {
|
|
109
|
+
const configPathResult = validatePathSegment(configPath, "configPath");
|
|
110
|
+
if (!configPathResult.ok) {
|
|
111
|
+
return {
|
|
112
|
+
status: "error",
|
|
113
|
+
message: configPathResult.reason,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const configPathValue = configPathResult.value;
|
|
118
|
+
if (!configPathValue.endsWith(".ts")) {
|
|
119
|
+
return {
|
|
120
|
+
status: "error",
|
|
121
|
+
message: `configPath must point to a .ts file: ${configPathValue}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const resolvedPath = resolve(projectRoot, configPathValue);
|
|
125
|
+
const underBase = resolvePathUnderBase(projectRoot, configPathValue);
|
|
126
|
+
if (!underBase.ok || resolve(underBase.path) !== resolvedPath) {
|
|
127
|
+
return {
|
|
128
|
+
status: "error",
|
|
129
|
+
message: `vmc config path escapes project root: ${configPathValue}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (!existsSync(resolvedPath)) {
|
|
133
|
+
return {
|
|
134
|
+
status: "error",
|
|
135
|
+
path: resolvedPath,
|
|
136
|
+
message: `vmc config not found: ${resolvedPath}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (!statSync(resolvedPath).isFile()) {
|
|
140
|
+
return {
|
|
141
|
+
status: "error",
|
|
142
|
+
path: resolvedPath,
|
|
143
|
+
message: `vmc config path must point to a file: ${resolvedPath}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (!pathIsUnderBase(projectRoot, resolvedPath)) {
|
|
147
|
+
return {
|
|
148
|
+
status: "error",
|
|
149
|
+
path: resolvedPath,
|
|
150
|
+
message: `vmc config path is outside project root after resolving symlinks: ${resolvedPath}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return resolvedPath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const name of VMC_CONFIG_NAMES) {
|
|
157
|
+
const candidate = join(projectRoot, name);
|
|
158
|
+
if (!existsSync(candidate)) continue;
|
|
159
|
+
if (!statSync(candidate).isFile()) continue;
|
|
160
|
+
if (!pathIsUnderBase(projectRoot, candidate)) {
|
|
161
|
+
return {
|
|
162
|
+
status: "error",
|
|
163
|
+
path: candidate,
|
|
164
|
+
message: `vmc config is outside project root after resolving symlinks: ${candidate}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return candidate;
|
|
168
|
+
}
|
|
169
|
+
return { status: "not-found" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function loadTsConfigModule(tsMod: typeof import("typescript"), configPath: string): unknown {
|
|
173
|
+
// vmc config is executable project code.
|
|
174
|
+
const sourceText = readFileSync(configPath, "utf8");
|
|
175
|
+
const transpiled = tsMod.transpileModule(sourceText, {
|
|
176
|
+
fileName: configPath,
|
|
177
|
+
compilerOptions: {
|
|
178
|
+
module: tsMod.ModuleKind.CommonJS,
|
|
179
|
+
target: tsMod.ScriptTarget.ES2020,
|
|
180
|
+
moduleResolution: tsMod.ModuleResolutionKind.NodeNext,
|
|
181
|
+
esModuleInterop: true,
|
|
182
|
+
},
|
|
183
|
+
reportDiagnostics: false,
|
|
184
|
+
}).outputText;
|
|
185
|
+
|
|
186
|
+
const localRequire = createRequire(configPath);
|
|
187
|
+
const module = { exports: {} as unknown };
|
|
188
|
+
const evaluate = runInThisContext(
|
|
189
|
+
`(function (exports, require, module, __filename, __dirname) {${transpiled}\n})`,
|
|
190
|
+
{ filename: configPath },
|
|
191
|
+
) as (
|
|
192
|
+
exportsObject: unknown,
|
|
193
|
+
requireFn: NodeJS.Require,
|
|
194
|
+
moduleObject: { exports: unknown },
|
|
195
|
+
filename: string,
|
|
196
|
+
dirname: string,
|
|
197
|
+
) => void;
|
|
198
|
+
|
|
199
|
+
evaluate(module.exports, localRequire, module, configPath, dirname(configPath));
|
|
200
|
+
return module.exports;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeConfigModule(
|
|
204
|
+
loadedModule: unknown,
|
|
205
|
+
): { readonly ok: true; readonly config: VmcConfig } | { readonly ok: false } {
|
|
206
|
+
const withDefault =
|
|
207
|
+
loadedModule && typeof loadedModule === "object" && "default" in loadedModule
|
|
208
|
+
? (loadedModule as { default: unknown }).default
|
|
209
|
+
: loadedModule;
|
|
210
|
+
|
|
211
|
+
if (!withDefault || typeof withDefault !== "object") {
|
|
212
|
+
return { ok: false };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const candidate = withDefault as {
|
|
216
|
+
resolver?: unknown;
|
|
217
|
+
plugins?: unknown;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (candidate.resolver !== undefined && !isResolverLike(candidate.resolver)) {
|
|
221
|
+
return { ok: false };
|
|
222
|
+
}
|
|
223
|
+
const normalizedPlugins = normalizePluginEntries(candidate.plugins);
|
|
224
|
+
if (!normalizedPlugins.ok) {
|
|
225
|
+
return { ok: false };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
ok: true,
|
|
230
|
+
config: {
|
|
231
|
+
...(candidate.resolver !== undefined
|
|
232
|
+
? { resolver: candidate.resolver as VirtualModuleResolver }
|
|
233
|
+
: {}),
|
|
234
|
+
...(normalizedPlugins.plugins.length > 0 ? { plugins: normalizedPlugins.plugins } : {}),
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function loadVmcConfig(options: LoadVmcConfigOptions): LoadVmcConfigResult {
|
|
240
|
+
let attemptedPath: string | undefined;
|
|
241
|
+
try {
|
|
242
|
+
const projectRootOrStatus = validateProjectRoot(options.projectRoot);
|
|
243
|
+
if (typeof projectRootOrStatus !== "string") return projectRootOrStatus;
|
|
244
|
+
const projectRoot = projectRootOrStatus;
|
|
245
|
+
|
|
246
|
+
const resolvedPathOrStatus = resolveConfigPath(projectRoot, options.configPath);
|
|
247
|
+
if (typeof resolvedPathOrStatus !== "string") return resolvedPathOrStatus;
|
|
248
|
+
const resolvedPath = resolvedPathOrStatus;
|
|
249
|
+
attemptedPath = resolvedPath;
|
|
250
|
+
|
|
251
|
+
const loadedModule = resolvedPath.endsWith(".ts")
|
|
252
|
+
? (() => {
|
|
253
|
+
if (!options.ts) {
|
|
254
|
+
return {
|
|
255
|
+
__vmcConfigLoaderError: "TypeScript module is required to load vmc.config.ts",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return loadTsConfigModule(options.ts, resolvedPath);
|
|
259
|
+
})()
|
|
260
|
+
: createRequire(resolvedPath)(resolvedPath);
|
|
261
|
+
|
|
262
|
+
if (
|
|
263
|
+
loadedModule &&
|
|
264
|
+
typeof loadedModule === "object" &&
|
|
265
|
+
"__vmcConfigLoaderError" in loadedModule
|
|
266
|
+
) {
|
|
267
|
+
return {
|
|
268
|
+
status: "error",
|
|
269
|
+
path: resolvedPath,
|
|
270
|
+
message: (loadedModule as { __vmcConfigLoaderError: string }).__vmcConfigLoaderError,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const normalized = normalizeConfigModule(loadedModule);
|
|
275
|
+
if (!normalized.ok) {
|
|
276
|
+
return {
|
|
277
|
+
status: "error",
|
|
278
|
+
path: resolvedPath,
|
|
279
|
+
message: `Invalid vmc config export in ${resolvedPath}`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
status: "loaded",
|
|
285
|
+
path: resolvedPath,
|
|
286
|
+
config: normalized.config,
|
|
287
|
+
};
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
status: "error",
|
|
291
|
+
...(attemptedPath ? { path: attemptedPath } : {}),
|
|
292
|
+
message: sanitizeErrorMessage(
|
|
293
|
+
`Failed to load vmc config${attemptedPath ? ` "${attemptedPath}"` : ""}: ${toMessage(error)}`,
|
|
294
|
+
),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { loadResolverFromVmcConfig } from "./VmcResolverLoader.js";
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
function createTempDir(): string {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "typed-vmc-resolver-"));
|
|
12
|
+
tempDirs.push(dir);
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
while (tempDirs.length > 0) {
|
|
18
|
+
const dir = tempDirs.pop();
|
|
19
|
+
if (dir) {
|
|
20
|
+
try {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore cleanup failures in test temp dirs
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("loadResolverFromVmcConfig", () => {
|
|
30
|
+
it("returns not-found when vmc config is missing", () => {
|
|
31
|
+
const dir = createTempDir();
|
|
32
|
+
const result = loadResolverFromVmcConfig({ projectRoot: dir, ts });
|
|
33
|
+
expect(result.status).toBe("not-found");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("uses exported resolver directly when provided", () => {
|
|
37
|
+
const dir = createTempDir();
|
|
38
|
+
writeFileSync(
|
|
39
|
+
join(dir, "vmc.config.ts"),
|
|
40
|
+
`const resolver = {
|
|
41
|
+
resolveModule: () => ({ status: "unresolved" }),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
resolver,
|
|
46
|
+
plugins: ["./missing-plugin.cjs"],
|
|
47
|
+
};
|
|
48
|
+
`,
|
|
49
|
+
"utf8",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const result = loadResolverFromVmcConfig({ projectRoot: dir, ts });
|
|
53
|
+
expect(result.status).toBe("loaded");
|
|
54
|
+
if (result.status !== "loaded") return;
|
|
55
|
+
|
|
56
|
+
expect(result.resolver).toBeDefined();
|
|
57
|
+
expect(result.pluginSpecifiers).toEqual(["./missing-plugin.cjs"]);
|
|
58
|
+
expect(result.pluginLoadErrors).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("collects typeTargetSpecs from plugins when they declare them", () => {
|
|
62
|
+
const dir = createTempDir();
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(dir, "file-plugin.cjs"),
|
|
65
|
+
`module.exports = {
|
|
66
|
+
name: "file-plugin",
|
|
67
|
+
typeTargetSpecs: [
|
|
68
|
+
{ id: "Route", module: "@typed/router", exportName: "Route" },
|
|
69
|
+
{ id: "Effect", module: "effect", exportName: "Effect" },
|
|
70
|
+
],
|
|
71
|
+
shouldResolve(id) {
|
|
72
|
+
return id === "virtual:file";
|
|
73
|
+
},
|
|
74
|
+
build() {
|
|
75
|
+
return "export const fileValue = 1;";
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
`,
|
|
79
|
+
"utf8",
|
|
80
|
+
);
|
|
81
|
+
writeFileSync(
|
|
82
|
+
join(dir, "vmc.config.ts"),
|
|
83
|
+
`export default {
|
|
84
|
+
plugins: ["./file-plugin.cjs"],
|
|
85
|
+
};
|
|
86
|
+
`,
|
|
87
|
+
"utf8",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const result = loadResolverFromVmcConfig({ projectRoot: dir, ts });
|
|
91
|
+
expect(result.status).toBe("loaded");
|
|
92
|
+
if (result.status !== "loaded") return;
|
|
93
|
+
|
|
94
|
+
expect(result.typeTargetSpecs).toBeDefined();
|
|
95
|
+
expect(result.typeTargetSpecs).toHaveLength(2);
|
|
96
|
+
expect(result.typeTargetSpecs?.[0]).toEqual({
|
|
97
|
+
id: "Route",
|
|
98
|
+
module: "@typed/router",
|
|
99
|
+
exportName: "Route",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("builds a plugin-manager resolver from plugin entries", () => {
|
|
104
|
+
const dir = createTempDir();
|
|
105
|
+
writeFileSync(
|
|
106
|
+
join(dir, "file-plugin.cjs"),
|
|
107
|
+
`module.exports = {
|
|
108
|
+
name: "file-plugin",
|
|
109
|
+
shouldResolve(id) {
|
|
110
|
+
return id === "virtual:file";
|
|
111
|
+
},
|
|
112
|
+
build() {
|
|
113
|
+
return "export const fileValue = 1;";
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
`,
|
|
117
|
+
"utf8",
|
|
118
|
+
);
|
|
119
|
+
writeFileSync(
|
|
120
|
+
join(dir, "vmc.config.ts"),
|
|
121
|
+
`const inlinePlugin = {
|
|
122
|
+
name: "inline-plugin",
|
|
123
|
+
shouldResolve: (id) => id === "virtual:inline",
|
|
124
|
+
build: () => "export const inlineValue = 2;",
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export default {
|
|
128
|
+
plugins: ["./file-plugin.cjs", inlinePlugin],
|
|
129
|
+
};
|
|
130
|
+
`,
|
|
131
|
+
"utf8",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const result = loadResolverFromVmcConfig({ projectRoot: dir, ts });
|
|
135
|
+
expect(result.status).toBe("loaded");
|
|
136
|
+
if (result.status !== "loaded") return;
|
|
137
|
+
|
|
138
|
+
expect(result.pluginSpecifiers).toEqual(["./file-plugin.cjs"]);
|
|
139
|
+
expect(result.pluginLoadErrors).toHaveLength(0);
|
|
140
|
+
expect(result.resolver).toBeDefined();
|
|
141
|
+
|
|
142
|
+
const fileResolution = result.resolver?.resolveModule({
|
|
143
|
+
id: "virtual:file",
|
|
144
|
+
importer: join(dir, "entry.ts"),
|
|
145
|
+
});
|
|
146
|
+
expect(fileResolution?.status).toBe("resolved");
|
|
147
|
+
if (fileResolution?.status === "resolved") {
|
|
148
|
+
expect(fileResolution.pluginName).toBe("file-plugin");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const inlineResolution = result.resolver?.resolveModule({
|
|
152
|
+
id: "virtual:inline",
|
|
153
|
+
importer: join(dir, "entry.ts"),
|
|
154
|
+
});
|
|
155
|
+
expect(inlineResolution?.status).toBe("resolved");
|
|
156
|
+
if (inlineResolution?.status === "resolved") {
|
|
157
|
+
expect(inlineResolution.pluginName).toBe("inline-plugin");
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("collects plugin load errors and returns no resolver when all fail", () => {
|
|
162
|
+
const dir = createTempDir();
|
|
163
|
+
writeFileSync(
|
|
164
|
+
join(dir, "vmc.config.ts"),
|
|
165
|
+
`export default {
|
|
166
|
+
plugins: ["./missing-plugin.cjs"],
|
|
167
|
+
};
|
|
168
|
+
`,
|
|
169
|
+
"utf8",
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const result = loadResolverFromVmcConfig({ projectRoot: dir, ts });
|
|
173
|
+
expect(result.status).toBe("loaded");
|
|
174
|
+
if (result.status !== "loaded") return;
|
|
175
|
+
|
|
176
|
+
expect(result.resolver).toBeUndefined();
|
|
177
|
+
expect(result.pluginSpecifiers).toEqual(["./missing-plugin.cjs"]);
|
|
178
|
+
expect(result.pluginLoadErrors).toHaveLength(1);
|
|
179
|
+
expect(result.pluginLoadErrors[0]?.code).toBe("module-not-found");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { collectTypeTargetSpecsFromPlugins } from "./collectTypeTargetSpecs.js";
|
|
3
|
+
import { NodeModulePluginLoader } from "./NodeModulePluginLoader.js";
|
|
4
|
+
import { PluginManager } from "./PluginManager.js";
|
|
5
|
+
import { loadVmcConfig } from "./VmcConfigLoader.js";
|
|
6
|
+
import type { VmcPluginEntry } from "./VmcConfigLoader.js";
|
|
7
|
+
import type {
|
|
8
|
+
NodeModulePluginLoadError,
|
|
9
|
+
TypeTargetSpec,
|
|
10
|
+
VirtualModulePlugin,
|
|
11
|
+
VirtualModuleResolver,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
function collectFromResolver(resolver: VirtualModuleResolver): readonly TypeTargetSpec[] | undefined {
|
|
15
|
+
const pm = resolver as { plugins?: readonly VirtualModulePlugin[] };
|
|
16
|
+
if (!Array.isArray(pm.plugins) || pm.plugins.length === 0) return undefined;
|
|
17
|
+
const merged = collectTypeTargetSpecsFromPlugins(pm.plugins);
|
|
18
|
+
return merged.length > 0 ? merged : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LoadPluginsFromEntriesResult {
|
|
22
|
+
readonly plugins: readonly VirtualModulePlugin[];
|
|
23
|
+
readonly pluginSpecifiers: readonly string[];
|
|
24
|
+
readonly errors: readonly NodeModulePluginLoadError[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function loadPluginsFromEntries(
|
|
28
|
+
entries: readonly VmcPluginEntry[],
|
|
29
|
+
baseDir: string,
|
|
30
|
+
): LoadPluginsFromEntriesResult {
|
|
31
|
+
const loader = new NodeModulePluginLoader();
|
|
32
|
+
const plugins: VirtualModulePlugin[] = [];
|
|
33
|
+
const pluginSpecifiers: string[] = [];
|
|
34
|
+
const errors: NodeModulePluginLoadError[] = [];
|
|
35
|
+
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (typeof entry === "string") {
|
|
38
|
+
pluginSpecifiers.push(entry);
|
|
39
|
+
const loaded = loader.load({ specifier: entry, baseDir });
|
|
40
|
+
if (loaded.status === "loaded") {
|
|
41
|
+
plugins.push(loaded.plugin);
|
|
42
|
+
} else {
|
|
43
|
+
errors.push(loaded);
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
plugins.push(entry);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
plugins,
|
|
53
|
+
pluginSpecifiers,
|
|
54
|
+
errors,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type LoadResolverFromVmcConfigResult =
|
|
59
|
+
| { readonly status: "not-found" }
|
|
60
|
+
| { readonly status: "error"; readonly path?: string; readonly message: string }
|
|
61
|
+
| {
|
|
62
|
+
readonly status: "loaded";
|
|
63
|
+
readonly path: string;
|
|
64
|
+
readonly resolver?: VirtualModuleResolver;
|
|
65
|
+
readonly pluginSpecifiers: readonly string[];
|
|
66
|
+
readonly pluginLoadErrors: readonly NodeModulePluginLoadError[];
|
|
67
|
+
/** Type target specs from config for structural assignability in TypeInfo API. */
|
|
68
|
+
readonly typeTargetSpecs?: readonly TypeTargetSpec[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export interface LoadResolverFromVmcConfigOptions {
|
|
72
|
+
readonly projectRoot: string;
|
|
73
|
+
readonly ts: typeof import("typescript");
|
|
74
|
+
readonly configPath?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function loadResolverFromVmcConfig(
|
|
78
|
+
options: LoadResolverFromVmcConfigOptions,
|
|
79
|
+
): LoadResolverFromVmcConfigResult {
|
|
80
|
+
const loadedVmcConfig = loadVmcConfig(options);
|
|
81
|
+
if (loadedVmcConfig.status === "not-found") {
|
|
82
|
+
return loadedVmcConfig;
|
|
83
|
+
}
|
|
84
|
+
if (loadedVmcConfig.status === "error") {
|
|
85
|
+
return loadedVmcConfig;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const vmcPlugins = loadedVmcConfig.config.plugins ?? [];
|
|
89
|
+
const pluginSpecifiers = vmcPlugins.filter((entry): entry is string => typeof entry === "string");
|
|
90
|
+
|
|
91
|
+
if (loadedVmcConfig.config.resolver) {
|
|
92
|
+
const resolver = loadedVmcConfig.config.resolver;
|
|
93
|
+
const typeTargetSpecs = collectFromResolver(resolver);
|
|
94
|
+
return {
|
|
95
|
+
status: "loaded",
|
|
96
|
+
path: loadedVmcConfig.path,
|
|
97
|
+
resolver,
|
|
98
|
+
pluginSpecifiers,
|
|
99
|
+
pluginLoadErrors: [],
|
|
100
|
+
...(typeTargetSpecs ? { typeTargetSpecs } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const loadedPlugins = loadPluginsFromEntries(vmcPlugins, dirname(loadedVmcConfig.path));
|
|
105
|
+
const resolver =
|
|
106
|
+
loadedPlugins.plugins.length > 0 ? new PluginManager(loadedPlugins.plugins) : undefined;
|
|
107
|
+
const typeTargetSpecs = resolver ? collectFromResolver(resolver) : undefined;
|
|
108
|
+
return {
|
|
109
|
+
status: "loaded",
|
|
110
|
+
path: loadedVmcConfig.path,
|
|
111
|
+
...(resolver ? { resolver } : {}),
|
|
112
|
+
pluginSpecifiers: loadedPlugins.pluginSpecifiers,
|
|
113
|
+
pluginLoadErrors: loadedPlugins.errors,
|
|
114
|
+
...(typeTargetSpecs ? { typeTargetSpecs } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|