@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,268 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { runInThisContext } from "node:vm";
|
|
5
|
+
import type {
|
|
6
|
+
NodeModulePluginLoadError,
|
|
7
|
+
NodeModulePluginLoadInput,
|
|
8
|
+
NodeModulePluginLoadResult,
|
|
9
|
+
NodeModulePluginRequest,
|
|
10
|
+
VirtualModulePlugin,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
export type { NodeModulePluginLoadInput };
|
|
13
|
+
import { pathIsUnderBase } from "./internal/path.js";
|
|
14
|
+
import { sanitizeErrorMessage } from "./internal/sanitize.js";
|
|
15
|
+
|
|
16
|
+
const toMessage = (error: unknown): string => {
|
|
17
|
+
if (error instanceof Error) {
|
|
18
|
+
return error.message;
|
|
19
|
+
}
|
|
20
|
+
return String(error);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const isPluginLike = (value: unknown): value is VirtualModulePlugin => {
|
|
24
|
+
if (!value || typeof value !== "object") {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const candidate = value as Record<string, unknown>;
|
|
29
|
+
return (
|
|
30
|
+
typeof candidate.name === "string" &&
|
|
31
|
+
typeof candidate.shouldResolve === "function" &&
|
|
32
|
+
typeof candidate.build === "function"
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const invalidPluginError = (
|
|
37
|
+
request: NodeModulePluginRequest,
|
|
38
|
+
message: string,
|
|
39
|
+
): NodeModulePluginLoadError => ({
|
|
40
|
+
status: "error",
|
|
41
|
+
specifier: request.specifier,
|
|
42
|
+
baseDir: request.baseDir,
|
|
43
|
+
code: "invalid-plugin-export",
|
|
44
|
+
message,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const loadFailedError = (
|
|
48
|
+
request: NodeModulePluginRequest,
|
|
49
|
+
message: string,
|
|
50
|
+
): NodeModulePluginLoadError => ({
|
|
51
|
+
status: "error",
|
|
52
|
+
specifier: request.specifier,
|
|
53
|
+
baseDir: request.baseDir,
|
|
54
|
+
code: "module-load-failed",
|
|
55
|
+
message,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const notFoundError = (
|
|
59
|
+
request: NodeModulePluginRequest,
|
|
60
|
+
message: string,
|
|
61
|
+
): NodeModulePluginLoadError => ({
|
|
62
|
+
status: "error",
|
|
63
|
+
specifier: request.specifier,
|
|
64
|
+
baseDir: request.baseDir,
|
|
65
|
+
code: "module-not-found",
|
|
66
|
+
message,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const pathEscapesError = (
|
|
70
|
+
request: NodeModulePluginRequest,
|
|
71
|
+
message: string,
|
|
72
|
+
): NodeModulePluginLoadError => ({
|
|
73
|
+
status: "error",
|
|
74
|
+
specifier: request.specifier,
|
|
75
|
+
baseDir: request.baseDir,
|
|
76
|
+
code: "path-escapes-base",
|
|
77
|
+
message,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const MAX_PATH_LENGTH = 4096;
|
|
81
|
+
|
|
82
|
+
const invalidRequestError = (message: string): NodeModulePluginLoadError => ({
|
|
83
|
+
status: "error",
|
|
84
|
+
specifier: "",
|
|
85
|
+
baseDir: "",
|
|
86
|
+
code: "invalid-request",
|
|
87
|
+
message,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const getErrorCode = (error: unknown): string | undefined =>
|
|
91
|
+
typeof error === "object" && error !== null && "code" in error
|
|
92
|
+
? String((error as { code?: unknown }).code)
|
|
93
|
+
: undefined;
|
|
94
|
+
|
|
95
|
+
export class NodeModulePluginLoader {
|
|
96
|
+
load(input: NodeModulePluginLoadInput): NodeModulePluginLoadResult {
|
|
97
|
+
if (isPluginLike(input)) {
|
|
98
|
+
return {
|
|
99
|
+
status: "loaded",
|
|
100
|
+
plugin: input,
|
|
101
|
+
resolvedPath: "<preloaded>",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const request = input;
|
|
106
|
+
if (typeof request.baseDir !== "string" || request.baseDir.trim() === "") {
|
|
107
|
+
return invalidRequestError("baseDir must be a non-empty string");
|
|
108
|
+
}
|
|
109
|
+
if (typeof request.specifier !== "string" || request.specifier.trim() === "") {
|
|
110
|
+
return invalidRequestError("specifier must be a non-empty string");
|
|
111
|
+
}
|
|
112
|
+
if (request.baseDir.length > MAX_PATH_LENGTH || request.specifier.length > MAX_PATH_LENGTH) {
|
|
113
|
+
return invalidRequestError(
|
|
114
|
+
`baseDir and specifier must be at most ${MAX_PATH_LENGTH} characters`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const require = createRequire(resolve(request.baseDir, "__typed_virtual_modules_loader__.cjs"));
|
|
119
|
+
let resolvedPath: string;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
resolvedPath = require.resolve(request.specifier, { paths: [request.baseDir] });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return notFoundError(
|
|
125
|
+
request,
|
|
126
|
+
`Could not resolve plugin "${request.specifier}" from "${request.baseDir}": ${sanitizeErrorMessage(toMessage(error))}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!pathIsUnderBase(request.baseDir, resolvedPath)) {
|
|
131
|
+
return pathEscapesError(
|
|
132
|
+
request,
|
|
133
|
+
sanitizeErrorMessage(
|
|
134
|
+
`Resolved plugin path "${resolvedPath}" is not under baseDir "${request.baseDir}"`,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let mod: unknown;
|
|
140
|
+
try {
|
|
141
|
+
mod = require(resolvedPath) as unknown;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const errorCode = getErrorCode(error);
|
|
144
|
+
if (errorCode === "ERR_REQUIRE_ASYNC_MODULE") {
|
|
145
|
+
return loadFailedError(
|
|
146
|
+
request,
|
|
147
|
+
sanitizeErrorMessage(
|
|
148
|
+
`Could not load plugin module "${resolvedPath}": plugin module uses top-level await and cannot be loaded synchronously`,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (errorCode === "ERR_REQUIRE_ESM") {
|
|
153
|
+
const esmFallback = this.#loadSyncEsmModule(resolvedPath, require);
|
|
154
|
+
if (esmFallback.status === "loaded") {
|
|
155
|
+
mod = esmFallback.moduleExport;
|
|
156
|
+
} else {
|
|
157
|
+
return loadFailedError(request, esmFallback.message);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
return loadFailedError(
|
|
161
|
+
request,
|
|
162
|
+
`Could not load plugin module "${resolvedPath}": ${sanitizeErrorMessage(toMessage(error))}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const normalizedPlugin = this.#normalizeModuleExport(mod);
|
|
168
|
+
if (!normalizedPlugin) {
|
|
169
|
+
return invalidPluginError(
|
|
170
|
+
request,
|
|
171
|
+
sanitizeErrorMessage(
|
|
172
|
+
`Resolved module "${resolvedPath}" does not export a valid VirtualModulePlugin`,
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
status: "loaded",
|
|
179
|
+
plugin: normalizedPlugin,
|
|
180
|
+
resolvedPath,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
loadMany(inputs: readonly NodeModulePluginLoadInput[]): readonly NodeModulePluginLoadResult[] {
|
|
185
|
+
return inputs.map((input) => this.load(input));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#loadSyncEsmModule(
|
|
189
|
+
resolvedPath: string,
|
|
190
|
+
localRequire: NodeJS.Require,
|
|
191
|
+
):
|
|
192
|
+
| { readonly status: "loaded"; readonly moduleExport: unknown }
|
|
193
|
+
| { readonly status: "error"; readonly message: string } {
|
|
194
|
+
let tsMod: typeof import("typescript");
|
|
195
|
+
try {
|
|
196
|
+
tsMod = localRequire("typescript") as typeof import("typescript");
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return loadFailedError(
|
|
199
|
+
{ specifier: resolvedPath, baseDir: dirname(resolvedPath) },
|
|
200
|
+
sanitizeErrorMessage(
|
|
201
|
+
`Could not load sync ESM plugin "${resolvedPath}": failed to load TypeScript for transpilation: ${toMessage(error)}`,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const sourceText = readFileSync(resolvedPath, "utf8");
|
|
208
|
+
const transpiled = tsMod.transpileModule(sourceText, {
|
|
209
|
+
fileName: resolvedPath,
|
|
210
|
+
compilerOptions: {
|
|
211
|
+
module: tsMod.ModuleKind.CommonJS,
|
|
212
|
+
target: tsMod.ScriptTarget.ES2020,
|
|
213
|
+
moduleResolution: tsMod.ModuleResolutionKind.NodeNext,
|
|
214
|
+
esModuleInterop: true,
|
|
215
|
+
allowSyntheticDefaultImports: true,
|
|
216
|
+
},
|
|
217
|
+
reportDiagnostics: false,
|
|
218
|
+
}).outputText;
|
|
219
|
+
|
|
220
|
+
const module = { exports: {} as unknown };
|
|
221
|
+
const evaluate = runInThisContext(
|
|
222
|
+
`(function (exports, require, module, __filename, __dirname) {${transpiled}\n})`,
|
|
223
|
+
{ filename: resolvedPath },
|
|
224
|
+
) as (
|
|
225
|
+
exportsObject: unknown,
|
|
226
|
+
requireFn: NodeJS.Require,
|
|
227
|
+
moduleObject: { exports: unknown },
|
|
228
|
+
filename: string,
|
|
229
|
+
dirname: string,
|
|
230
|
+
) => void;
|
|
231
|
+
evaluate(module.exports, localRequire, module, resolvedPath, dirname(resolvedPath));
|
|
232
|
+
|
|
233
|
+
return { status: "loaded", moduleExport: module.exports };
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return {
|
|
236
|
+
status: "error",
|
|
237
|
+
message: sanitizeErrorMessage(
|
|
238
|
+
`Could not load sync ESM plugin "${resolvedPath}": ${toMessage(error)}`,
|
|
239
|
+
),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#normalizeModuleExport(mod: unknown): VirtualModulePlugin | undefined {
|
|
245
|
+
if (isPluginLike(mod)) {
|
|
246
|
+
return mod;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!mod || typeof mod !== "object") {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const candidate = mod as {
|
|
254
|
+
default?: unknown;
|
|
255
|
+
plugin?: unknown;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (isPluginLike(candidate.default)) {
|
|
259
|
+
return candidate.default;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (isPluginLike(candidate.plugin)) {
|
|
263
|
+
return candidate.plugin;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { PluginManager } from "./PluginManager.js";
|
|
3
|
+
|
|
4
|
+
describe("PluginManager", () => {
|
|
5
|
+
it("uses first matching plugin only", () => {
|
|
6
|
+
const firstBuild = vi.fn(() => "export const value = 1;");
|
|
7
|
+
const secondBuild = vi.fn(() => "export const value = 2;");
|
|
8
|
+
|
|
9
|
+
const manager = new PluginManager([
|
|
10
|
+
{
|
|
11
|
+
name: "first",
|
|
12
|
+
shouldResolve: () => true,
|
|
13
|
+
build: firstBuild,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "second",
|
|
17
|
+
shouldResolve: () => true,
|
|
18
|
+
build: secondBuild,
|
|
19
|
+
},
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const resolved = manager.resolveModule({
|
|
23
|
+
id: "virtual:test",
|
|
24
|
+
importer: "/project/src/main.ts",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(resolved.status).toBe("resolved");
|
|
28
|
+
if (resolved.status !== "resolved") return;
|
|
29
|
+
expect(resolved.pluginName).toBe("first");
|
|
30
|
+
expect(firstBuild).toHaveBeenCalledTimes(1);
|
|
31
|
+
expect(secondBuild).toHaveBeenCalledTimes(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns unresolved when no plugin matches", () => {
|
|
35
|
+
const manager = new PluginManager([
|
|
36
|
+
{
|
|
37
|
+
name: "noop",
|
|
38
|
+
shouldResolve: () => false,
|
|
39
|
+
build: () => "export {};",
|
|
40
|
+
},
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
expect(
|
|
44
|
+
manager.resolveModule({
|
|
45
|
+
id: "virtual:none",
|
|
46
|
+
importer: "/project/src/main.ts",
|
|
47
|
+
}),
|
|
48
|
+
).toEqual({ status: "unresolved" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns structured error when shouldResolve throws", () => {
|
|
52
|
+
const manager = new PluginManager([
|
|
53
|
+
{
|
|
54
|
+
name: "broken",
|
|
55
|
+
shouldResolve: () => {
|
|
56
|
+
throw new Error("explode");
|
|
57
|
+
},
|
|
58
|
+
build: () => "export {};",
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const resolved = manager.resolveModule({
|
|
63
|
+
id: "virtual:bad",
|
|
64
|
+
importer: "/project/src/main.ts",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(resolved.status).toBe("error");
|
|
68
|
+
if (resolved.status !== "error") return;
|
|
69
|
+
expect(resolved.diagnostic.code).toBe("plugin-should-resolve-threw");
|
|
70
|
+
expect(resolved.diagnostic.message).toContain("explode");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns invalid-options for empty id", () => {
|
|
74
|
+
const manager = new PluginManager([
|
|
75
|
+
{ name: "any", shouldResolve: () => true, build: () => "export {};" },
|
|
76
|
+
]);
|
|
77
|
+
const resolved = manager.resolveModule({
|
|
78
|
+
id: "",
|
|
79
|
+
importer: "/project/src/main.ts",
|
|
80
|
+
});
|
|
81
|
+
expect(resolved.status).toBe("error");
|
|
82
|
+
if (resolved.status !== "error") return;
|
|
83
|
+
expect(resolved.diagnostic.code).toBe("invalid-options");
|
|
84
|
+
expect(resolved.diagnostic.message).toContain("id");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns invalid-options for empty importer", () => {
|
|
88
|
+
const manager = new PluginManager([
|
|
89
|
+
{ name: "any", shouldResolve: () => true, build: () => "export {};" },
|
|
90
|
+
]);
|
|
91
|
+
const resolved = manager.resolveModule({
|
|
92
|
+
id: "virtual:x",
|
|
93
|
+
importer: "",
|
|
94
|
+
});
|
|
95
|
+
expect(resolved.status).toBe("error");
|
|
96
|
+
if (resolved.status !== "error") return;
|
|
97
|
+
expect(resolved.diagnostic.code).toBe("invalid-options");
|
|
98
|
+
expect(resolved.diagnostic.message).toContain("importer");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns invalid-options when a plugin has empty name", () => {
|
|
102
|
+
const manager = new PluginManager([
|
|
103
|
+
{ name: "", shouldResolve: () => true, build: () => "export {};" },
|
|
104
|
+
]);
|
|
105
|
+
const resolved = manager.resolveModule({
|
|
106
|
+
id: "virtual:x",
|
|
107
|
+
importer: "/project/src/main.ts",
|
|
108
|
+
});
|
|
109
|
+
expect(resolved.status).toBe("error");
|
|
110
|
+
if (resolved.status !== "error") return;
|
|
111
|
+
expect(resolved.diagnostic.code).toBe("invalid-options");
|
|
112
|
+
expect(resolved.diagnostic.message).toContain("Plugin name");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns plugin-build-threw when plugin build() throws", () => {
|
|
116
|
+
const manager = new PluginManager([
|
|
117
|
+
{
|
|
118
|
+
name: "throws",
|
|
119
|
+
shouldResolve: () => true,
|
|
120
|
+
build: () => {
|
|
121
|
+
throw new Error("build exploded");
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
const resolved = manager.resolveModule({
|
|
126
|
+
id: "virtual:x",
|
|
127
|
+
importer: "/project/src/main.ts",
|
|
128
|
+
});
|
|
129
|
+
expect(resolved.status).toBe("error");
|
|
130
|
+
if (resolved.status !== "error") return;
|
|
131
|
+
expect(resolved.diagnostic.code).toBe("plugin-build-threw");
|
|
132
|
+
expect(resolved.diagnostic.message).toContain("build exploded");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("returns invalid-build-output when plugin returns non-string", () => {
|
|
136
|
+
const manager = new PluginManager([
|
|
137
|
+
{
|
|
138
|
+
name: "bad-return",
|
|
139
|
+
shouldResolve: () => true,
|
|
140
|
+
build: () => null as unknown as string,
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
const resolved = manager.resolveModule({
|
|
144
|
+
id: "virtual:x",
|
|
145
|
+
importer: "/project/src/main.ts",
|
|
146
|
+
});
|
|
147
|
+
expect(resolved.status).toBe("error");
|
|
148
|
+
if (resolved.status !== "error") return;
|
|
149
|
+
expect(resolved.diagnostic.code).toBe("invalid-build-output");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns unresolved when plugin list is empty", () => {
|
|
153
|
+
const manager = new PluginManager([]);
|
|
154
|
+
const resolved = manager.resolveModule({
|
|
155
|
+
id: "virtual:any",
|
|
156
|
+
importer: "/project/src/main.ts",
|
|
157
|
+
});
|
|
158
|
+
expect(resolved.status).toBe("unresolved");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns session-creation-failed when createTypeInfoApiSession throws", () => {
|
|
162
|
+
const manager = new PluginManager([
|
|
163
|
+
{ name: "needs-session", shouldResolve: () => true, build: () => "export {};" },
|
|
164
|
+
]);
|
|
165
|
+
const resolved = manager.resolveModule({
|
|
166
|
+
id: "virtual:x",
|
|
167
|
+
importer: "/project/src/main.ts",
|
|
168
|
+
createTypeInfoApiSession: () => {
|
|
169
|
+
throw new Error("session factory error");
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
expect(resolved.status).toBe("error");
|
|
173
|
+
if (resolved.status !== "error") return;
|
|
174
|
+
expect(resolved.diagnostic.code).toBe("session-creation-failed");
|
|
175
|
+
expect(resolved.diagnostic.message).toContain("Session creation failed");
|
|
176
|
+
expect(resolved.diagnostic.message).toContain("session factory error");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { sanitizeErrorMessage } from "./internal/sanitize.js";
|
|
2
|
+
import { validateNonEmptyString } from "./internal/validation.js";
|
|
3
|
+
import type {
|
|
4
|
+
ResolveVirtualModuleOptions,
|
|
5
|
+
TypeInfoApi,
|
|
6
|
+
TypeInfoApiSession,
|
|
7
|
+
VirtualModuleBuildResult,
|
|
8
|
+
VirtualModuleDiagnostic,
|
|
9
|
+
VirtualModulePlugin,
|
|
10
|
+
VirtualModuleResolution,
|
|
11
|
+
VirtualModuleResolver,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
import {
|
|
14
|
+
isVirtualModuleBuildError,
|
|
15
|
+
isVirtualModuleBuildSuccess,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* TypeInfoApi used when createTypeInfoApiSession is not provided.
|
|
20
|
+
* Returns safe defaults instead of throwing.
|
|
21
|
+
* Hosts should always supply createTypeInfoApiSession when plugins use api.file(), api.directory(), or api.resolveExport() for correct behavior.
|
|
22
|
+
*/
|
|
23
|
+
const noopTypeInfoApi: TypeInfoApi = {
|
|
24
|
+
file: () => ({ ok: false as const, error: "invalid-input" }),
|
|
25
|
+
directory: () => [],
|
|
26
|
+
resolveExport: () => undefined,
|
|
27
|
+
isAssignableTo: () => false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const noopSession: TypeInfoApiSession = {
|
|
31
|
+
api: noopTypeInfoApi,
|
|
32
|
+
consumeDependencies: () => [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const toMessage = (error: unknown): string => {
|
|
36
|
+
if (error instanceof Error) {
|
|
37
|
+
return error.message;
|
|
38
|
+
}
|
|
39
|
+
return String(error);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const createDiagnostic = (
|
|
43
|
+
code: VirtualModuleDiagnostic["code"],
|
|
44
|
+
pluginName: string,
|
|
45
|
+
message: string,
|
|
46
|
+
): VirtualModuleDiagnostic => ({
|
|
47
|
+
code,
|
|
48
|
+
pluginName,
|
|
49
|
+
message,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export class PluginManager implements VirtualModuleResolver {
|
|
53
|
+
readonly #plugins: VirtualModulePlugin[];
|
|
54
|
+
|
|
55
|
+
constructor(plugins: readonly VirtualModulePlugin[] = []) {
|
|
56
|
+
this.#plugins = [...plugins];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get plugins(): readonly VirtualModulePlugin[] {
|
|
60
|
+
return this.#plugins;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
register(plugin: VirtualModulePlugin): void {
|
|
64
|
+
this.#plugins.push(plugin);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
registerMany(plugins: readonly VirtualModulePlugin[]): void {
|
|
68
|
+
for (const plugin of plugins) {
|
|
69
|
+
this.register(plugin);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
resolveModule(options: ResolveVirtualModuleOptions): VirtualModuleResolution {
|
|
74
|
+
const idResult = validateNonEmptyString(options.id, "options.id");
|
|
75
|
+
if (!idResult.ok) {
|
|
76
|
+
return {
|
|
77
|
+
status: "error",
|
|
78
|
+
diagnostic: createDiagnostic("invalid-options", "", idResult.reason),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const importerResult = validateNonEmptyString(options.importer, "options.importer");
|
|
82
|
+
if (!importerResult.ok) {
|
|
83
|
+
return {
|
|
84
|
+
status: "error",
|
|
85
|
+
diagnostic: createDiagnostic("invalid-options", "", importerResult.reason),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const createSession = options.createTypeInfoApiSession;
|
|
90
|
+
|
|
91
|
+
for (const plugin of this.#plugins) {
|
|
92
|
+
const nameResult = validateNonEmptyString(plugin.name, "Plugin name");
|
|
93
|
+
if (!nameResult.ok) {
|
|
94
|
+
return {
|
|
95
|
+
status: "error",
|
|
96
|
+
diagnostic: createDiagnostic("invalid-options", "", nameResult.reason),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const shouldResolve = this.#safeShouldResolve(plugin, options.id, options.importer);
|
|
100
|
+
|
|
101
|
+
if (shouldResolve.status === "error") {
|
|
102
|
+
return shouldResolve;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!shouldResolve.value) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let session: TypeInfoApiSession;
|
|
110
|
+
try {
|
|
111
|
+
session = createSession?.({ id: options.id, importer: options.importer }) ?? noopSession;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const msg = toMessage(error);
|
|
114
|
+
// Treat temporary unavailability (e.g. program not loaded) as unresolved so retry can succeed later.
|
|
115
|
+
if (
|
|
116
|
+
msg.includes("Program not yet available") ||
|
|
117
|
+
msg.includes("TypeInfo session creation failed")
|
|
118
|
+
) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
status: "error",
|
|
123
|
+
diagnostic: createDiagnostic(
|
|
124
|
+
"session-creation-failed",
|
|
125
|
+
plugin.name,
|
|
126
|
+
`Session creation failed: ${sanitizeErrorMessage(msg)}`,
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const result: VirtualModuleBuildResult = plugin.build(
|
|
133
|
+
options.id,
|
|
134
|
+
options.importer,
|
|
135
|
+
session.api,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (typeof result === "string") {
|
|
139
|
+
return {
|
|
140
|
+
status: "resolved",
|
|
141
|
+
pluginName: plugin.name,
|
|
142
|
+
sourceText: result,
|
|
143
|
+
dependencies: session.consumeDependencies(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (isVirtualModuleBuildError(result)) {
|
|
148
|
+
const first = result.errors[0];
|
|
149
|
+
const diagnostic: VirtualModuleDiagnostic =
|
|
150
|
+
first &&
|
|
151
|
+
typeof first === "object" &&
|
|
152
|
+
typeof first.code === "string" &&
|
|
153
|
+
typeof first.message === "string" &&
|
|
154
|
+
typeof first.pluginName === "string"
|
|
155
|
+
? first
|
|
156
|
+
: createDiagnostic(
|
|
157
|
+
"invalid-build-output",
|
|
158
|
+
plugin.name,
|
|
159
|
+
`Plugin "${plugin.name}" returned errors with invalid diagnostic shape`,
|
|
160
|
+
);
|
|
161
|
+
return { status: "error", diagnostic };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (isVirtualModuleBuildSuccess(result)) {
|
|
165
|
+
return {
|
|
166
|
+
status: "resolved",
|
|
167
|
+
pluginName: plugin.name,
|
|
168
|
+
sourceText: result.sourceText,
|
|
169
|
+
dependencies: session.consumeDependencies(),
|
|
170
|
+
...(result.warnings?.length ? { warnings: result.warnings } : {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
status: "error",
|
|
176
|
+
diagnostic: createDiagnostic(
|
|
177
|
+
"invalid-build-output",
|
|
178
|
+
plugin.name,
|
|
179
|
+
`Plugin "${plugin.name}" returned a non-string build result`,
|
|
180
|
+
),
|
|
181
|
+
};
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
status: "error",
|
|
185
|
+
diagnostic: createDiagnostic(
|
|
186
|
+
"plugin-build-threw",
|
|
187
|
+
plugin.name,
|
|
188
|
+
`Plugin "${plugin.name}" build failed: ${sanitizeErrorMessage(toMessage(error))}`,
|
|
189
|
+
),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { status: "unresolved" };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#safeShouldResolve(
|
|
198
|
+
plugin: VirtualModulePlugin,
|
|
199
|
+
id: string,
|
|
200
|
+
importer: string,
|
|
201
|
+
): { status: "ok"; value: boolean } | { status: "error"; diagnostic: VirtualModuleDiagnostic } {
|
|
202
|
+
try {
|
|
203
|
+
return {
|
|
204
|
+
status: "ok",
|
|
205
|
+
value: plugin.shouldResolve(id, importer),
|
|
206
|
+
};
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return {
|
|
209
|
+
status: "error",
|
|
210
|
+
diagnostic: createDiagnostic(
|
|
211
|
+
"plugin-should-resolve-threw",
|
|
212
|
+
plugin.name,
|
|
213
|
+
`Plugin "${plugin.name}" shouldResolve failed: ${sanitizeErrorMessage(toMessage(error))}`,
|
|
214
|
+
),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|