@typed/virtual-modules-ts-plugin 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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@typed/virtual-modules-ts-plugin",
3
+ "version": "1.0.0-beta.1",
4
+ "type": "commonjs",
5
+ "main": "dist/plugin.js",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/plugin.d.ts",
9
+ "require": "./dist/plugin.js"
10
+ }
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "scripts": {
16
+ "build": "tsup && cp src/plugin.d.ts dist/plugin.d.ts",
17
+ "build:plugins": "pnpm --filter virtual-modules-ts-plugin-sample run build:plugins",
18
+ "pretest": "pnpm run build && pnpm --filter @typed/virtual-modules-compiler run build",
19
+ "test": "vitest run --passWithNoTests"
20
+ },
21
+ "dependencies": {
22
+ "@typed/virtual-modules": "workspace:*"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^25.3.0",
26
+ "tsup": "^8.5.1",
27
+ "typescript": "catalog:",
28
+ "vitest": "catalog:"
29
+ },
30
+ "peerDependencies": {
31
+ "typescript": ">=5.0.0"
32
+ },
33
+ "optionalDependencies": {
34
+ "@typed/app": "workspace:*"
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ]
40
+ }
@@ -0,0 +1,16 @@
1
+ import type * as ts from "typescript";
2
+
3
+ interface VirtualModulesTsPluginConfig {
4
+ readonly debounceMs?: number;
5
+ readonly vmcConfigPath?: string;
6
+ }
7
+
8
+ declare function init(modules: { typescript: typeof import("typescript") }): {
9
+ create: (info: {
10
+ languageService: ts.LanguageService;
11
+ project: ts.LanguageServiceHost;
12
+ config?: VirtualModulesTsPluginConfig;
13
+ }) => ts.LanguageService;
14
+ };
15
+
16
+ export = init;
@@ -0,0 +1,419 @@
1
+ /// <reference types="node" />
2
+ import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join, sep } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import ts from "typescript";
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+ import {
10
+ attachLanguageServiceAdapter,
11
+ NodeModulePluginLoader,
12
+ PluginManager,
13
+ } from "@typed/virtual-modules";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const require = createRequire(import.meta.url);
17
+ const tempDirs: string[] = [];
18
+
19
+ const TEST_WORKSPACE = join(
20
+ __dirname,
21
+ "..",
22
+ "..",
23
+ "..",
24
+ ".test-workspace",
25
+ "virtual-modules-ts-plugin",
26
+ );
27
+
28
+ function createTempDir(): string {
29
+ const dir = mkdtempSync(join(tmpdir(), "typed-vm-tsplugin-"));
30
+ tempDirs.push(dir);
31
+ return dir;
32
+ }
33
+
34
+ function createTempDirInWorkspace(): string {
35
+ mkdirSync(TEST_WORKSPACE, { recursive: true });
36
+ const dir = realpathSync(mkdtempSync(join(TEST_WORKSPACE, "run-")));
37
+ tempDirs.push(dir);
38
+ return dir;
39
+ }
40
+
41
+ function createTempDirCanonical(): string {
42
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "typed-vm-tsplugin-")));
43
+ tempDirs.push(dir);
44
+ return dir;
45
+ }
46
+
47
+ afterEach(() => {
48
+ while (tempDirs.length > 0) {
49
+ const dir = tempDirs.pop();
50
+ if (dir) {
51
+ try {
52
+ rmSync(dir, { recursive: true, force: true });
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ }
57
+ }
58
+ });
59
+
60
+ describe("virtual-modules-ts-plugin", () => {
61
+ it("builds and exposes init function", () => {
62
+ const pluginPath = join(__dirname, "..", "dist", "plugin.js");
63
+ const init = require(pluginPath) as (modules: { typescript: typeof import("typescript") }) => {
64
+ create: (info: unknown) => unknown;
65
+ };
66
+ expect(typeof init).toBe("function");
67
+ const result = init({ typescript: ts });
68
+ expect(result).toBeDefined();
69
+ expect(typeof result.create).toBe("function");
70
+ });
71
+
72
+ it("loads plugin from disk via NodeModulePluginLoader", () => {
73
+ const dir = createTempDirCanonical();
74
+ writeFileSync(
75
+ join(dir, "test-plugin.mjs"),
76
+ `export default {
77
+ name: "test-virtual",
78
+ shouldResolve: (id) => id === "virtual:foo",
79
+ build: () => "export interface Foo { n: number }"
80
+ };
81
+ `,
82
+ "utf8",
83
+ );
84
+ const loader = new NodeModulePluginLoader();
85
+ const result = loader.load({ specifier: "./test-plugin.mjs", baseDir: dir });
86
+ if (result.status === "error") {
87
+ throw new Error(`Plugin load failed: ${result.message} (code: ${result.code})`);
88
+ }
89
+ expect(result.status).toBe("loaded");
90
+ if (result.status === "loaded") {
91
+ expect(result.plugin.name).toBe("test-virtual");
92
+ }
93
+ });
94
+
95
+ it(
96
+ "attaches adapter and resolves virtual modules when create() is called",
97
+ { timeout: 15_000 },
98
+ () => {
99
+ const dir = createTempDirInWorkspace();
100
+ const pluginPath = join(dir, "test-plugin.mjs");
101
+ writeFileSync(
102
+ pluginPath,
103
+ `export default {
104
+ name: "test-virtual",
105
+ shouldResolve: (id) => id === "virtual:foo",
106
+ build: () => "export interface Foo { n: number }"
107
+ };
108
+ `,
109
+ "utf8",
110
+ );
111
+ writeFileSync(
112
+ join(dir, "vmc.config.ts"),
113
+ `export default { plugins: ["./test-plugin.mjs"] };`,
114
+ "utf8",
115
+ );
116
+
117
+ const entryPath = join(dir, "entry.ts");
118
+ writeFileSync(
119
+ entryPath,
120
+ 'import type { Foo } from "virtual:foo";\nexport const value: Foo = { n: 1 };\n',
121
+ "utf8",
122
+ );
123
+
124
+ const compilerOptions: ts.CompilerOptions = {
125
+ strict: true,
126
+ noEmit: true,
127
+ target: ts.ScriptTarget.ESNext,
128
+ module: ts.ModuleKind.ESNext,
129
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
130
+ skipLibCheck: true,
131
+ };
132
+
133
+ const host: ts.LanguageServiceHost = {
134
+ getCompilationSettings: () => compilerOptions,
135
+ getScriptFileNames: () => [entryPath],
136
+ getScriptVersion: () => "1",
137
+ getScriptSnapshot: (fileName: string) => {
138
+ const content = ts.sys.readFile(fileName);
139
+ return content != null ? ts.ScriptSnapshot.fromString(content) : undefined;
140
+ },
141
+ getCurrentDirectory: () => dir,
142
+ getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
143
+ fileExists: (fileName) => ts.sys.fileExists(fileName),
144
+ readFile: (fileName) => ts.sys.readFile(fileName),
145
+ readDirectory: (path, extensions, exclude, include, depth) =>
146
+ path === dir || path.startsWith(dir + sep)
147
+ ? ["entry.ts", "test-plugin.mjs", "vmc.config.ts"]
148
+ : ts.sys.readDirectory(path, extensions, exclude, include, depth),
149
+ };
150
+
151
+ const languageService = ts.createLanguageService(host);
152
+
153
+ const pluginDistPath = join(__dirname, "..", "dist", "plugin.js");
154
+ const init = require(pluginDistPath) as (modules: {
155
+ typescript: typeof import("typescript");
156
+ }) => {
157
+ create: (info: {
158
+ languageService: ts.LanguageService;
159
+ project: ts.LanguageServiceHost;
160
+ config?: unknown;
161
+ }) => ts.LanguageService;
162
+ };
163
+
164
+ const { create } = init({ typescript: ts });
165
+ const wrapped = create({
166
+ languageService,
167
+ project: host,
168
+ config: {},
169
+ });
170
+
171
+ const diagnostics = wrapped.getSemanticDiagnostics(entryPath);
172
+ expect(diagnostics).toHaveLength(0);
173
+
174
+ const program = wrapped.getProgram();
175
+ expect(program).toBeDefined();
176
+ expect(program!.getSourceFiles().some((sf) => sf.fileName.includes("__virtual_"))).toBe(true);
177
+ },
178
+ );
179
+
180
+ it("loads plugins from vmc.config.ts when tsconfig plugin list is omitted", () => {
181
+ const dir = createTempDirInWorkspace();
182
+ writeFileSync(
183
+ join(dir, "vmc.config.ts"),
184
+ `const plugin = {
185
+ name: "test-virtual",
186
+ shouldResolve: (id) => id === "virtual:foo",
187
+ build: () => "export interface Foo { n: number }"
188
+ };
189
+
190
+ export default { plugins: [plugin] };
191
+ `,
192
+ "utf8",
193
+ );
194
+
195
+ const entryPath = join(dir, "entry.ts");
196
+ writeFileSync(
197
+ entryPath,
198
+ 'import type { Foo } from "virtual:foo";\nexport const value: Foo = { n: 1 };\n',
199
+ "utf8",
200
+ );
201
+
202
+ const compilerOptions: ts.CompilerOptions = {
203
+ strict: true,
204
+ noEmit: true,
205
+ target: ts.ScriptTarget.ESNext,
206
+ module: ts.ModuleKind.ESNext,
207
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
208
+ skipLibCheck: true,
209
+ };
210
+
211
+ const host: ts.LanguageServiceHost = {
212
+ getCompilationSettings: () => compilerOptions,
213
+ getScriptFileNames: () => [entryPath],
214
+ getScriptVersion: () => "1",
215
+ getScriptSnapshot: (fileName: string) => {
216
+ const content = ts.sys.readFile(fileName);
217
+ return content != null ? ts.ScriptSnapshot.fromString(content) : undefined;
218
+ },
219
+ getCurrentDirectory: () => dir,
220
+ getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
221
+ fileExists: (fileName) => ts.sys.fileExists(fileName),
222
+ readFile: (fileName) => ts.sys.readFile(fileName),
223
+ readDirectory: (...args) => ts.sys.readDirectory(...args),
224
+ };
225
+
226
+ const languageService = ts.createLanguageService(host);
227
+ const pluginDistPath = join(__dirname, "..", "dist", "plugin.js");
228
+ const init = require(pluginDistPath) as (modules: {
229
+ typescript: typeof import("typescript");
230
+ }) => {
231
+ create: (info: {
232
+ languageService: ts.LanguageService;
233
+ project: ts.LanguageServiceHost;
234
+ config?: unknown;
235
+ }) => ts.LanguageService;
236
+ };
237
+
238
+ const { create } = init({ typescript: ts });
239
+ const wrapped = create({
240
+ languageService,
241
+ project: host,
242
+ config: {},
243
+ });
244
+
245
+ const diagnostics = wrapped.getSemanticDiagnostics(entryPath);
246
+ expect(diagnostics).toHaveLength(0);
247
+ const program = wrapped.getProgram();
248
+ expect(program).toBeDefined();
249
+ expect(program!.getSourceFiles().some((sf) => sf.fileName.includes("__virtual_"))).toBe(true);
250
+ });
251
+
252
+ it(
253
+ "resolves TypeInfo-dependent virtual modules on first boot when getProgram() is initially undefined",
254
+ { timeout: 15_000 },
255
+ () => {
256
+ const dir = createTempDirInWorkspace();
257
+ writeFileSync(
258
+ join(dir, "tsconfig.json"),
259
+ JSON.stringify({
260
+ compilerOptions: {
261
+ strict: true,
262
+ noEmit: true,
263
+ target: "ESNext",
264
+ module: "ESNext",
265
+ moduleResolution: "bundler",
266
+ skipLibCheck: true,
267
+ },
268
+ }),
269
+ "utf8",
270
+ );
271
+ writeFileSync(
272
+ join(dir, "vmc.config.ts"),
273
+ `const path = require("path");
274
+ const plugin = {
275
+ name: "typeinfo-boot-test",
276
+ shouldResolve: (id) => id === "typeinfo:boot-test",
277
+ build: (id, importer, api) => {
278
+ api.directory("*.ts", { baseDir: path.dirname(importer), recursive: false });
279
+ return { sourceText: "export const x = 1;" };
280
+ },
281
+ };
282
+ export default { plugins: [plugin] };
283
+ `,
284
+ "utf8",
285
+ );
286
+ const entryPath = join(dir, "entry.ts");
287
+ writeFileSync(
288
+ entryPath,
289
+ 'import { x } from "typeinfo:boot-test";\nexport const value = x;\n',
290
+ "utf8",
291
+ );
292
+
293
+ const compilerOptions: ts.CompilerOptions = {
294
+ strict: true,
295
+ noEmit: true,
296
+ target: ts.ScriptTarget.ESNext,
297
+ module: ts.ModuleKind.ESNext,
298
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
299
+ skipLibCheck: true,
300
+ };
301
+
302
+ const host: ts.LanguageServiceHost & { configFilePath?: string } = {
303
+ getCompilationSettings: () => compilerOptions,
304
+ getScriptFileNames: () => [entryPath],
305
+ getScriptVersion: () => "1",
306
+ getScriptSnapshot: (fileName: string) => {
307
+ const content = ts.sys.readFile(fileName);
308
+ return content != null ? ts.ScriptSnapshot.fromString(content) : undefined;
309
+ },
310
+ getCurrentDirectory: () => dir,
311
+ getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
312
+ fileExists: (fileName) => ts.sys.fileExists(fileName),
313
+ readFile: (fileName) => ts.sys.readFile(fileName),
314
+ readDirectory: (path, extensions, exclude, include, depth) =>
315
+ path === dir || path.startsWith(dir + sep)
316
+ ? ["entry.ts", "vmc.config.ts", "tsconfig.json"]
317
+ : ts.sys.readDirectory(path, extensions, exclude, include, depth),
318
+ };
319
+ host.configFilePath = join(dir, "tsconfig.json");
320
+
321
+ const realLS = ts.createLanguageService(host);
322
+ let getProgramCallCount = 0;
323
+ const wrappedLS: ts.LanguageService = {
324
+ ...realLS,
325
+ getProgram: () => {
326
+ getProgramCallCount++;
327
+ if (getProgramCallCount <= 1) return undefined;
328
+ return realLS.getProgram();
329
+ },
330
+ };
331
+
332
+ const pluginDistPath = join(__dirname, "..", "dist", "plugin.js");
333
+ const init = require(pluginDistPath) as (modules: {
334
+ typescript: typeof import("typescript");
335
+ }) => {
336
+ create: (info: {
337
+ languageService: ts.LanguageService;
338
+ project: ts.LanguageServiceHost;
339
+ config?: unknown;
340
+ }) => ts.LanguageService;
341
+ };
342
+
343
+ const { create } = init({ typescript: ts });
344
+ const wrapped = create({
345
+ languageService: wrappedLS,
346
+ project: host,
347
+ config: {},
348
+ });
349
+
350
+ const diagnostics = wrapped.getSemanticDiagnostics(entryPath);
351
+ expect(diagnostics).toHaveLength(0);
352
+
353
+ const program = wrapped.getProgram();
354
+ expect(program).toBeDefined();
355
+ expect(program!.getSourceFiles().some((sf) => sf.fileName.includes("__virtual_"))).toBe(true);
356
+ },
357
+ );
358
+
359
+ it("resolves virtual modules when using attachLanguageServiceAdapter directly (adapter works)", () => {
360
+ const dir = createTempDir();
361
+ const entryPath = join(dir, "entry.ts");
362
+ writeFileSync(
363
+ entryPath,
364
+ 'import type { Foo } from "virtual:foo";\nexport const value: Foo = { n: 1 };\n',
365
+ "utf8",
366
+ );
367
+
368
+ const compilerOptions: ts.CompilerOptions = {
369
+ strict: true,
370
+ noEmit: true,
371
+ target: ts.ScriptTarget.ESNext,
372
+ module: ts.ModuleKind.ESNext,
373
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
374
+ skipLibCheck: true,
375
+ };
376
+
377
+ const host: ts.LanguageServiceHost = {
378
+ getCompilationSettings: () => compilerOptions,
379
+ getScriptFileNames: () => [entryPath],
380
+ getScriptVersion: () => "1",
381
+ getScriptSnapshot: (fileName: string) => {
382
+ const content = ts.sys.readFile(fileName);
383
+ return content != null ? ts.ScriptSnapshot.fromString(content) : undefined;
384
+ },
385
+ getCurrentDirectory: () => dir,
386
+ getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
387
+ fileExists: (fileName) => ts.sys.fileExists(fileName),
388
+ readFile: (fileName) => ts.sys.readFile(fileName),
389
+ readDirectory: (...args) => ts.sys.readDirectory(...args),
390
+ };
391
+
392
+ const manager = new PluginManager([
393
+ {
394
+ name: "virtual",
395
+ shouldResolve: (id) => id === "virtual:foo",
396
+ build: () => "export interface Foo { n: number }",
397
+ },
398
+ ]);
399
+
400
+ const languageService = ts.createLanguageService(host);
401
+ const handle = attachLanguageServiceAdapter({
402
+ ts,
403
+ languageService,
404
+ languageServiceHost: host,
405
+ resolver: manager,
406
+ projectRoot: dir,
407
+ });
408
+
409
+ try {
410
+ const diagnostics = languageService.getSemanticDiagnostics(entryPath);
411
+ expect(diagnostics).toHaveLength(0);
412
+ const program = languageService.getProgram();
413
+ expect(program).toBeDefined();
414
+ expect(program!.getSourceFiles().some((sf) => sf.fileName.includes("__virtual_"))).toBe(true);
415
+ } finally {
416
+ handle.dispose();
417
+ }
418
+ });
419
+ });