@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,22 @@
|
|
|
1
|
+
import type { TypeTargetSpec, VirtualModulePlugin } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collects and merges typeTargetSpecs from plugins. Dedupes by id (first wins).
|
|
5
|
+
*/
|
|
6
|
+
export function collectTypeTargetSpecsFromPlugins(
|
|
7
|
+
plugins: readonly VirtualModulePlugin[],
|
|
8
|
+
): readonly TypeTargetSpec[] {
|
|
9
|
+
const seen = new Set<string>();
|
|
10
|
+
const result: TypeTargetSpec[] = [];
|
|
11
|
+
for (const plugin of plugins) {
|
|
12
|
+
const specs = plugin.typeTargetSpecs;
|
|
13
|
+
if (!specs?.length) continue;
|
|
14
|
+
for (const spec of specs) {
|
|
15
|
+
if (!seen.has(spec.id)) {
|
|
16
|
+
seen.add(spec.id);
|
|
17
|
+
result.push(spec);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./PluginManager.js";
|
|
3
|
+
export { collectTypeTargetSpecsFromPlugins } from "./collectTypeTargetSpecs.js";
|
|
4
|
+
export {
|
|
5
|
+
createTypeInfoApiSession,
|
|
6
|
+
createTypeInfoApiSessionFactory,
|
|
7
|
+
createTypeTargetBootstrapContent,
|
|
8
|
+
resolveTypeTargetsFromSpecs,
|
|
9
|
+
type CreateTypeInfoApiSessionOptions,
|
|
10
|
+
type ResolvedTypeTarget,
|
|
11
|
+
} from "./TypeInfoApi.js";
|
|
12
|
+
export * from "./NodeModulePluginLoader.js";
|
|
13
|
+
export * from "./LanguageServiceAdapter.js";
|
|
14
|
+
export * from "./CompilerHostAdapter.js";
|
|
15
|
+
export * from "./VmcConfigLoader.js";
|
|
16
|
+
export * from "./VmcResolverLoader.js";
|
|
17
|
+
export {
|
|
18
|
+
createVirtualFileName,
|
|
19
|
+
createVirtualKey,
|
|
20
|
+
VIRTUAL_MODULE_URI_SCHEME,
|
|
21
|
+
VIRTUAL_NODE_MODULES_RELATIVE,
|
|
22
|
+
type CreateVirtualFileNameParams,
|
|
23
|
+
type CreateVirtualFileNameOptions,
|
|
24
|
+
} from "./internal/path.js";
|
|
25
|
+
export {
|
|
26
|
+
ensureTypeTargetBootstrapFile,
|
|
27
|
+
getProgramWithTypeTargetBootstrap,
|
|
28
|
+
getTypeTargetBootstrapPath,
|
|
29
|
+
TYPE_TARGET_BOOTSTRAP_RELATIVE,
|
|
30
|
+
type EnsureTypeTargetBootstrapFileFs,
|
|
31
|
+
} from "./typeTargetBootstrap.js";
|
|
32
|
+
export {
|
|
33
|
+
createLanguageServiceSessionFactory,
|
|
34
|
+
type CreateLanguageServiceSessionFactoryOptions,
|
|
35
|
+
} from "./LanguageServiceSession.js";
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import type * as ts from "typescript";
|
|
2
|
+
import type {
|
|
3
|
+
ResolveVirtualModuleOptions,
|
|
4
|
+
VirtualModuleDiagnostic,
|
|
5
|
+
VirtualModuleRecord,
|
|
6
|
+
VirtualModuleResolution,
|
|
7
|
+
} from "../types.js";
|
|
8
|
+
import { createVirtualFileName, createVirtualKey, createWatchDescriptorKey } from "./path.js";
|
|
9
|
+
|
|
10
|
+
export type MutableVirtualRecord = Omit<VirtualModuleRecord, "version" | "stale"> & {
|
|
11
|
+
version: number;
|
|
12
|
+
stale: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface ResolveRecordResultResolved {
|
|
16
|
+
readonly status: "resolved";
|
|
17
|
+
readonly record: MutableVirtualRecord;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ResolveRecordResultUnresolved {
|
|
21
|
+
readonly status: "unresolved";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResolveRecordResultError {
|
|
25
|
+
readonly status: "error";
|
|
26
|
+
readonly diagnostic: VirtualModuleDiagnostic;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ResolveRecordResult =
|
|
30
|
+
| ResolveRecordResultResolved
|
|
31
|
+
| ResolveRecordResultUnresolved
|
|
32
|
+
| ResolveRecordResultError;
|
|
33
|
+
|
|
34
|
+
export interface VirtualRecordStoreOptions {
|
|
35
|
+
readonly projectRoot: string;
|
|
36
|
+
readonly resolver: {
|
|
37
|
+
resolveModule(options: ResolveVirtualModuleOptions): VirtualModuleResolution;
|
|
38
|
+
};
|
|
39
|
+
readonly createTypeInfoApiSession?: ResolveVirtualModuleOptions["createTypeInfoApiSession"];
|
|
40
|
+
readonly debounceMs?: number;
|
|
41
|
+
readonly watchHost?: {
|
|
42
|
+
watchFile?(path: string, callback: () => void): ts.FileWatcher;
|
|
43
|
+
watchDirectory?(path: string, callback: () => void, recursive?: boolean): ts.FileWatcher;
|
|
44
|
+
};
|
|
45
|
+
/** Used by evictStaleImporters: records for which this returns true are evicted. */
|
|
46
|
+
readonly shouldEvictRecord: (record: MutableVirtualRecord) => boolean;
|
|
47
|
+
/** Called when flushPendingStale runs (after debounce). LS uses for epoch++. */
|
|
48
|
+
readonly onFlushStale?: () => void;
|
|
49
|
+
/** Called when a record is marked stale (immediate or after flush). CH uses for invalidatedPaths. */
|
|
50
|
+
readonly onMarkStale?: (record: MutableVirtualRecord) => void;
|
|
51
|
+
/** Called at start of resolveRecord (e.g. LS sets inResolveRecord). */
|
|
52
|
+
readonly onBeforeResolve?: () => void;
|
|
53
|
+
/** Called in finally after resolveRecord (e.g. LS clears inResolveRecord). */
|
|
54
|
+
readonly onAfterResolve?: () => void;
|
|
55
|
+
/** Called after a record is stored and watchers registered (e.g. LS clears diagnostics for importer). */
|
|
56
|
+
readonly onRecordResolved?: (record: MutableVirtualRecord) => void;
|
|
57
|
+
/** Called when a record is evicted (e.g. LS clears diagnostics for importer). */
|
|
58
|
+
readonly onEvictRecord?: (record: MutableVirtualRecord) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function toResolvedModule(
|
|
62
|
+
tsMod: typeof import("typescript"),
|
|
63
|
+
fileName: string,
|
|
64
|
+
): ts.ResolvedModuleFull {
|
|
65
|
+
return {
|
|
66
|
+
resolvedFileName: fileName,
|
|
67
|
+
extension: tsMod.Extension.Ts,
|
|
68
|
+
isExternalLibraryImport: false,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createVirtualRecordStore(options: VirtualRecordStoreOptions) {
|
|
73
|
+
const recordsByKey = new Map<string, MutableVirtualRecord>();
|
|
74
|
+
const recordsByVirtualFile = new Map<string, MutableVirtualRecord>();
|
|
75
|
+
const descriptorToVirtualKeys = new Map<string, Set<string>>();
|
|
76
|
+
const watcherByDescriptor = new Map<string, ts.FileWatcher>();
|
|
77
|
+
|
|
78
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
+
const pendingStaleKeys = new Set<string>();
|
|
80
|
+
const {
|
|
81
|
+
debounceMs,
|
|
82
|
+
watchHost,
|
|
83
|
+
shouldEvictRecord,
|
|
84
|
+
onFlushStale,
|
|
85
|
+
onMarkStale,
|
|
86
|
+
onBeforeResolve,
|
|
87
|
+
onAfterResolve,
|
|
88
|
+
onRecordResolved,
|
|
89
|
+
onEvictRecord,
|
|
90
|
+
} = options;
|
|
91
|
+
|
|
92
|
+
const evictRecord = (record: MutableVirtualRecord): void => {
|
|
93
|
+
onEvictRecord?.(record);
|
|
94
|
+
recordsByKey.delete(record.key);
|
|
95
|
+
recordsByVirtualFile.delete(record.virtualFileName);
|
|
96
|
+
for (const descriptor of record.dependencies) {
|
|
97
|
+
const descriptorKey = createWatchDescriptorKey(descriptor);
|
|
98
|
+
const dependents = descriptorToVirtualKeys.get(descriptorKey);
|
|
99
|
+
if (dependents) {
|
|
100
|
+
dependents.delete(record.key);
|
|
101
|
+
if (dependents.size === 0) {
|
|
102
|
+
descriptorToVirtualKeys.delete(descriptorKey);
|
|
103
|
+
const watcher = watcherByDescriptor.get(descriptorKey);
|
|
104
|
+
if (watcher) {
|
|
105
|
+
watcher.close();
|
|
106
|
+
watcherByDescriptor.delete(descriptorKey);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const evictStaleImporters = (): void => {
|
|
114
|
+
const toEvict: MutableVirtualRecord[] = [];
|
|
115
|
+
for (const record of recordsByKey.values()) {
|
|
116
|
+
if (shouldEvictRecord(record)) {
|
|
117
|
+
toEvict.push(record);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const record of toEvict) {
|
|
121
|
+
evictRecord(record);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const registerWatchers = (record: MutableVirtualRecord): void => {
|
|
126
|
+
for (const descriptor of record.dependencies) {
|
|
127
|
+
const descriptorKey = createWatchDescriptorKey(descriptor);
|
|
128
|
+
const dependents = descriptorToVirtualKeys.get(descriptorKey) ?? new Set<string>();
|
|
129
|
+
dependents.add(record.key);
|
|
130
|
+
descriptorToVirtualKeys.set(descriptorKey, dependents);
|
|
131
|
+
|
|
132
|
+
if (watcherByDescriptor.has(descriptorKey)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (descriptor.type === "file" && watchHost?.watchFile) {
|
|
137
|
+
const watcher = watchHost.watchFile(descriptor.path, () => {
|
|
138
|
+
markStale(descriptorKey);
|
|
139
|
+
});
|
|
140
|
+
watcherByDescriptor.set(descriptorKey, watcher);
|
|
141
|
+
} else if (descriptor.type === "glob" && watchHost?.watchDirectory) {
|
|
142
|
+
const watcher = watchHost.watchDirectory(
|
|
143
|
+
descriptor.baseDir,
|
|
144
|
+
() => {
|
|
145
|
+
markStale(descriptorKey);
|
|
146
|
+
},
|
|
147
|
+
descriptor.recursive,
|
|
148
|
+
);
|
|
149
|
+
watcherByDescriptor.set(descriptorKey, watcher);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const flushPendingStale = (): void => {
|
|
155
|
+
if (pendingStaleKeys.size === 0) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
onFlushStale?.();
|
|
159
|
+
for (const descriptorKey of pendingStaleKeys) {
|
|
160
|
+
const keys = descriptorToVirtualKeys.get(descriptorKey);
|
|
161
|
+
if (!keys || keys.size === 0) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
for (const key of keys) {
|
|
165
|
+
const record = recordsByKey.get(key);
|
|
166
|
+
if (record) {
|
|
167
|
+
record.stale = true;
|
|
168
|
+
onMarkStale?.(record);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
pendingStaleKeys.clear();
|
|
173
|
+
debounceTimer = undefined;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const markStale = (descriptorKey: string): void => {
|
|
177
|
+
if (debounceMs !== undefined && debounceMs > 0) {
|
|
178
|
+
pendingStaleKeys.add(descriptorKey);
|
|
179
|
+
if (debounceTimer === undefined) {
|
|
180
|
+
debounceTimer = setTimeout(() => {
|
|
181
|
+
flushPendingStale();
|
|
182
|
+
}, debounceMs);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const keys = descriptorToVirtualKeys.get(descriptorKey);
|
|
188
|
+
if (!keys || keys.size === 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const key of keys) {
|
|
193
|
+
const record = recordsByKey.get(key);
|
|
194
|
+
if (record) {
|
|
195
|
+
record.stale = true;
|
|
196
|
+
onMarkStale?.(record);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const resolveRecord = (
|
|
202
|
+
id: string,
|
|
203
|
+
importer: string,
|
|
204
|
+
previous?: MutableVirtualRecord,
|
|
205
|
+
): ResolveRecordResult => {
|
|
206
|
+
onBeforeResolve?.();
|
|
207
|
+
try {
|
|
208
|
+
const resolveOptions: ResolveVirtualModuleOptions = {
|
|
209
|
+
id,
|
|
210
|
+
importer,
|
|
211
|
+
createTypeInfoApiSession: options.createTypeInfoApiSession,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const resolution = options.resolver.resolveModule(resolveOptions);
|
|
215
|
+
if (resolution.status === "unresolved") {
|
|
216
|
+
return { status: "unresolved" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (resolution.status === "error") {
|
|
220
|
+
return {
|
|
221
|
+
status: "error",
|
|
222
|
+
diagnostic: resolution.diagnostic,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const key = createVirtualKey(id, importer);
|
|
227
|
+
const virtualFileName = createVirtualFileName(
|
|
228
|
+
resolution.pluginName,
|
|
229
|
+
key,
|
|
230
|
+
{ id, importer },
|
|
231
|
+
{ projectRoot: options.projectRoot },
|
|
232
|
+
);
|
|
233
|
+
const record: MutableVirtualRecord = {
|
|
234
|
+
key,
|
|
235
|
+
id,
|
|
236
|
+
importer,
|
|
237
|
+
pluginName: resolution.pluginName,
|
|
238
|
+
virtualFileName,
|
|
239
|
+
sourceText: resolution.sourceText,
|
|
240
|
+
dependencies: resolution.dependencies,
|
|
241
|
+
...(resolution.warnings?.length ? { warnings: resolution.warnings } : {}),
|
|
242
|
+
version: previous ? previous.version + 1 : 1,
|
|
243
|
+
stale: false,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
recordsByKey.set(key, record);
|
|
247
|
+
recordsByVirtualFile.set(virtualFileName, record);
|
|
248
|
+
registerWatchers(record);
|
|
249
|
+
onRecordResolved?.(record);
|
|
250
|
+
return {
|
|
251
|
+
status: "resolved",
|
|
252
|
+
record,
|
|
253
|
+
};
|
|
254
|
+
} finally {
|
|
255
|
+
onAfterResolve?.();
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const getOrBuildRecord = (id: string, importer: string): ResolveRecordResult => {
|
|
260
|
+
evictStaleImporters();
|
|
261
|
+
|
|
262
|
+
const key = createVirtualKey(id, importer);
|
|
263
|
+
const existing = recordsByKey.get(key);
|
|
264
|
+
if (existing && !existing.stale) {
|
|
265
|
+
return {
|
|
266
|
+
status: "resolved",
|
|
267
|
+
record: existing,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return resolveRecord(id, importer, existing);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const dispose = (): void => {
|
|
275
|
+
if (debounceTimer !== undefined) {
|
|
276
|
+
clearTimeout(debounceTimer);
|
|
277
|
+
debounceTimer = undefined;
|
|
278
|
+
}
|
|
279
|
+
pendingStaleKeys.clear();
|
|
280
|
+
|
|
281
|
+
for (const watcher of watcherByDescriptor.values()) {
|
|
282
|
+
watcher.close();
|
|
283
|
+
}
|
|
284
|
+
watcherByDescriptor.clear();
|
|
285
|
+
descriptorToVirtualKeys.clear();
|
|
286
|
+
recordsByKey.clear();
|
|
287
|
+
recordsByVirtualFile.clear();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
recordsByKey,
|
|
292
|
+
recordsByVirtualFile,
|
|
293
|
+
descriptorToVirtualKeys,
|
|
294
|
+
watcherByDescriptor,
|
|
295
|
+
evictRecord,
|
|
296
|
+
evictStaleImporters,
|
|
297
|
+
registerWatchers,
|
|
298
|
+
markStale,
|
|
299
|
+
flushPendingStale,
|
|
300
|
+
resolveRecord,
|
|
301
|
+
getOrBuildRecord,
|
|
302
|
+
dispose,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { toPosixPath } from "./path.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rewrite relative import specifiers in sourceText so they resolve correctly when
|
|
7
|
+
* the file is placed in previewDir instead of importerDir.
|
|
8
|
+
*/
|
|
9
|
+
export function rewriteSourceForPreviewLocation(
|
|
10
|
+
sourceText: string,
|
|
11
|
+
importer: string,
|
|
12
|
+
virtualFilePath: string,
|
|
13
|
+
): string {
|
|
14
|
+
const importerDir = dirname(resolve(importer));
|
|
15
|
+
const previewDir = dirname(resolve(virtualFilePath));
|
|
16
|
+
return sourceText.replace(/from\s+['"](\.\.?\/[^'"]+)['"]/g, (match, spec: string) => {
|
|
17
|
+
const absoluteTarget = resolve(importerDir, spec);
|
|
18
|
+
const newRel = toPosixPath(relative(previewDir, absoluteTarget));
|
|
19
|
+
const newSpec = newRel.startsWith(".") ? newRel : `./${newRel}`;
|
|
20
|
+
const quote = match.includes('"') ? '"' : "'";
|
|
21
|
+
return `from ${quote}${newSpec}${quote}`;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Materialize virtual module content to disk at virtualFilePath. Rewrites relative
|
|
27
|
+
* imports so they resolve from the virtual file's location. Used so go-to-definition
|
|
28
|
+
* can open the file (path must exist on disk).
|
|
29
|
+
*/
|
|
30
|
+
export function materializeVirtualFile(
|
|
31
|
+
virtualFilePath: string,
|
|
32
|
+
importer: string,
|
|
33
|
+
sourceText: string,
|
|
34
|
+
): void {
|
|
35
|
+
const rewritten = rewriteSourceForPreviewLocation(sourceText, importer, virtualFilePath);
|
|
36
|
+
mkdirSync(dirname(resolve(virtualFilePath)), { recursive: true });
|
|
37
|
+
writeFileSync(virtualFilePath, rewritten, "utf8");
|
|
38
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { posix } from "node:path";
|
|
5
|
+
import type { WatchDependencyDescriptor } from "../types.js";
|
|
6
|
+
|
|
7
|
+
/** Base directory for virtual files under node_modules (enables go-to-definition to resolve correctly). */
|
|
8
|
+
export const VIRTUAL_NODE_MODULES_RELATIVE = "node_modules/.typed/virtual";
|
|
9
|
+
|
|
10
|
+
export const toPosixPath = (path: string): string => path.replaceAll("\\", "/");
|
|
11
|
+
|
|
12
|
+
export const resolveRelativePath = (baseDir: string, relativePath: string): string =>
|
|
13
|
+
toPosixPath(resolve(baseDir, relativePath));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves relativePath against baseDir and ensures the result stays under baseDir.
|
|
17
|
+
* Use for plugin/caller-controlled paths to prevent path traversal.
|
|
18
|
+
*/
|
|
19
|
+
export function resolvePathUnderBase(
|
|
20
|
+
baseDir: string,
|
|
21
|
+
relativePath: string,
|
|
22
|
+
): { ok: true; path: string } | { ok: false; error: "path-escapes-base" } {
|
|
23
|
+
const baseAbs = resolve(baseDir);
|
|
24
|
+
const resolved = resolve(baseDir, relativePath);
|
|
25
|
+
const rel = relative(baseAbs, resolved);
|
|
26
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
27
|
+
return { ok: false, error: "path-escapes-base" };
|
|
28
|
+
}
|
|
29
|
+
return { ok: true, path: toPosixPath(resolved) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Returns true if absolutePath is under baseDir (or equal). Canonicalizes with realpath so symlinks (e.g. /tmp) match. */
|
|
33
|
+
export function pathIsUnderBase(baseDir: string, absolutePath: string): boolean {
|
|
34
|
+
let baseAbs: string;
|
|
35
|
+
let resolvedAbs: string;
|
|
36
|
+
try {
|
|
37
|
+
baseAbs = realpathSync(resolve(baseDir));
|
|
38
|
+
resolvedAbs = realpathSync(resolve(absolutePath));
|
|
39
|
+
} catch {
|
|
40
|
+
baseAbs = resolve(baseDir);
|
|
41
|
+
resolvedAbs = resolve(absolutePath);
|
|
42
|
+
}
|
|
43
|
+
const rel = relative(baseAbs, resolvedAbs);
|
|
44
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const stableHash = (input: string): string =>
|
|
51
|
+
createHash("sha1").update(input).digest("hex").slice(0, 16);
|
|
52
|
+
|
|
53
|
+
const sanitizeSegment = (value: string): string => value.replaceAll(/[^a-zA-Z0-9._-]/g, "-");
|
|
54
|
+
|
|
55
|
+
/** URI scheme for virtual module identifiers (e.g. `typed-virtual://0/...`). Single source of truth for all consumers. */
|
|
56
|
+
export const VIRTUAL_MODULE_URI_SCHEME = "typed-virtual" as const;
|
|
57
|
+
|
|
58
|
+
export const createVirtualKey = (id: string, importer: string): string => `${importer}::${id}`;
|
|
59
|
+
|
|
60
|
+
export interface CreateVirtualFileNameParams {
|
|
61
|
+
readonly id: string;
|
|
62
|
+
readonly importer: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CreateVirtualFileNameOptions {
|
|
66
|
+
/** When provided, virtual files use projectRoot/node_modules/.typed/virtual/ so go-to-definition resolves correctly. */
|
|
67
|
+
readonly projectRoot?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Virtual file name for the TS program. When projectRoot is provided, uses
|
|
72
|
+
* node_modules/.typed/virtual/ so the path exists on disk (after materialization)
|
|
73
|
+
* and go-to-definition works like node_modules packages. Falls back to
|
|
74
|
+
* importer-adjacent path or typed-virtual:// URI when projectRoot is not set.
|
|
75
|
+
*/
|
|
76
|
+
export const createVirtualFileName = (
|
|
77
|
+
pluginName: string,
|
|
78
|
+
virtualKey: string,
|
|
79
|
+
params?: CreateVirtualFileNameParams,
|
|
80
|
+
options?: CreateVirtualFileNameOptions,
|
|
81
|
+
): string => {
|
|
82
|
+
const safePluginName = sanitizeSegment(pluginName);
|
|
83
|
+
const hash = stableHash(virtualKey);
|
|
84
|
+
const basename = `__virtual_${safePluginName}_${hash}.ts`;
|
|
85
|
+
if (params) {
|
|
86
|
+
const projectRoot = options?.projectRoot;
|
|
87
|
+
if (typeof projectRoot === "string" && projectRoot.trim().length > 0) {
|
|
88
|
+
return toPosixPath(join(resolve(projectRoot), VIRTUAL_NODE_MODULES_RELATIVE, basename));
|
|
89
|
+
}
|
|
90
|
+
const importerDir = dirname(toPosixPath(params.importer));
|
|
91
|
+
return `${importerDir}/${basename}`;
|
|
92
|
+
}
|
|
93
|
+
return `${VIRTUAL_MODULE_URI_SCHEME}://0/${safePluginName}/${hash}.ts`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const createWatchDescriptorKey = (descriptor: WatchDependencyDescriptor): string => {
|
|
97
|
+
if (descriptor.type === "file") {
|
|
98
|
+
return `file:${toPosixPath(descriptor.path)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const globs = [...descriptor.relativeGlobs].sort().join("|");
|
|
102
|
+
return `glob:${toPosixPath(descriptor.baseDir)}:${descriptor.recursive ? "r" : "nr"}:${globs}`;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const dedupeSorted = (values: readonly string[]): readonly string[] =>
|
|
106
|
+
[...new Set(values.map(toPosixPath))].sort();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitizes error messages for structured diagnostics: strips stack traces and
|
|
3
|
+
* redacts absolute paths to avoid leaking filesystem layout.
|
|
4
|
+
*/
|
|
5
|
+
export function sanitizeErrorMessage(message: string): string {
|
|
6
|
+
const withoutStack = message
|
|
7
|
+
.split(/\r?\n/)
|
|
8
|
+
.filter((line) => !/^\s*at\s+/.test(line.trim()))
|
|
9
|
+
.join("\n")
|
|
10
|
+
.trim();
|
|
11
|
+
|
|
12
|
+
const unixPath = /\/[\w./-]+\/[\w./-]+(?:\/[\w./-]*)*/g;
|
|
13
|
+
const winPath = /[A-Za-z]:[\\/][\w.-]+(?:[\\/][\w.-]+)*/g;
|
|
14
|
+
let out = withoutStack.replace(unixPath, "[path]").replace(winPath, "[path]");
|
|
15
|
+
return out.replace(/\s{2,}/g, " ").trim();
|
|
16
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal TypeScript API access.
|
|
3
|
+
*
|
|
4
|
+
* The TypeScript Compiler API does not expose all capabilities we need for
|
|
5
|
+
* type-target resolution, assignability fallbacks, and serialization. This
|
|
6
|
+
* module centralizes use of non-public APIs (via type casts) so that:
|
|
7
|
+
* - All such use is in one place and documented.
|
|
8
|
+
* - Callers can guard with try/catch and optional chaining.
|
|
9
|
+
* - Upgrades to new TS versions can be handled here.
|
|
10
|
+
*
|
|
11
|
+
* These APIs are not part of the public TypeScript contract and may change
|
|
12
|
+
* across versions. Tested with TypeScript 5.x.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type * as ts from "typescript";
|
|
16
|
+
|
|
17
|
+
/** Index signature entry shape from internal getIndexInfosOfType (not in public .d.ts). */
|
|
18
|
+
export interface IndexInfo {
|
|
19
|
+
readonly keyType: ts.Type;
|
|
20
|
+
readonly type: ts.Type;
|
|
21
|
+
readonly isReadonly?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get index signature key/value types for an object type when available.
|
|
26
|
+
* Uses internal TypeChecker.getIndexInfosOfType; may change in future TS versions.
|
|
27
|
+
* Returns undefined when the API is missing or throws.
|
|
28
|
+
*/
|
|
29
|
+
export function getIndexInfosOfType(
|
|
30
|
+
type: ts.Type,
|
|
31
|
+
checker: ts.TypeChecker,
|
|
32
|
+
): readonly IndexInfo[] | undefined {
|
|
33
|
+
try {
|
|
34
|
+
const fn = (checker as ts.TypeChecker & { getIndexInfosOfType?(t: ts.Type): readonly IndexInfo[] })
|
|
35
|
+
.getIndexInfosOfType;
|
|
36
|
+
return fn?.(type);
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve an alias symbol to the symbol it refers to (re-exports, import-then-export).
|
|
44
|
+
* Only call when symbol.flags includes SymbolFlags.Alias.
|
|
45
|
+
* Uses internal TypeChecker.getAliasedSymbol; may change in future TS versions.
|
|
46
|
+
* Returns the original symbol when the API is missing or throws.
|
|
47
|
+
*/
|
|
48
|
+
export function getAliasedSymbol(
|
|
49
|
+
symbol: ts.Symbol,
|
|
50
|
+
checker: ts.TypeChecker,
|
|
51
|
+
tsMod: typeof import("typescript"),
|
|
52
|
+
): ts.Symbol {
|
|
53
|
+
if ((symbol.flags & tsMod.SymbolFlags.Alias) === 0) return symbol;
|
|
54
|
+
try {
|
|
55
|
+
const aliased = (
|
|
56
|
+
checker as ts.TypeChecker & { getAliasedSymbol(s: ts.Symbol): ts.Symbol }
|
|
57
|
+
).getAliasedSymbol(symbol);
|
|
58
|
+
return aliased ?? symbol;
|
|
59
|
+
} catch {
|
|
60
|
+
return symbol;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the base (generic declaration) type from a TypeReference.
|
|
66
|
+
* Uses internal (type as TypeReference).target; may change in future TS versions.
|
|
67
|
+
* Returns undefined when not a TypeReference with target or when access throws.
|
|
68
|
+
*/
|
|
69
|
+
export function getTypeReferenceTarget(
|
|
70
|
+
type: ts.Type,
|
|
71
|
+
checker: ts.TypeChecker,
|
|
72
|
+
): (ts.Type & { symbol?: ts.Symbol }) | undefined {
|
|
73
|
+
try {
|
|
74
|
+
const args = checker.getTypeArguments(type as ts.TypeReference);
|
|
75
|
+
if (args.length > 0) {
|
|
76
|
+
const target = (type as ts.TypeReference & { target?: ts.Type }).target;
|
|
77
|
+
return target as (ts.Type & { symbol?: ts.Symbol }) | undefined;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
/* getTypeArguments can throw for some type shapes */
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get the symbol attached to a type when available (e.g. interface, class, type alias).
|
|
87
|
+
* Uses internal (type as Type).symbol; may change in future TS versions.
|
|
88
|
+
*/
|
|
89
|
+
export function getTypeSymbol(type: ts.Type): ts.Symbol | undefined {
|
|
90
|
+
return (type as ts.Type & { symbol?: ts.Symbol }).symbol;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get namespace/merged declaration exports from a symbol when available.
|
|
95
|
+
* Uses internal (symbol as Symbol).exports; may change in future TS versions.
|
|
96
|
+
* Prefer checker.getExportsOfModule(moduleSymbol) for module symbols when applicable.
|
|
97
|
+
*/
|
|
98
|
+
export function getSymbolExports(
|
|
99
|
+
symbol: ts.Symbol,
|
|
100
|
+
): Map<unknown, ts.Symbol> | undefined {
|
|
101
|
+
return (symbol as ts.Symbol & { exports?: Map<unknown, ts.Symbol> }).exports;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get base types of an interface/class for inheritance checks.
|
|
106
|
+
* Uses internal TypeChecker.getBaseTypes; may change in future TS versions.
|
|
107
|
+
* Returns undefined when the API is missing or throws (e.g. type is not interface or class).
|
|
108
|
+
*/
|
|
109
|
+
export function getBaseTypes(
|
|
110
|
+
type: ts.InterfaceType,
|
|
111
|
+
checker: ts.TypeChecker,
|
|
112
|
+
): ts.Type[] | undefined {
|
|
113
|
+
try {
|
|
114
|
+
const fn = (checker as ts.TypeChecker & { getBaseTypes?(t: ts.InterfaceType): ts.Type[] })
|
|
115
|
+
.getBaseTypes;
|
|
116
|
+
return fn?.(type);
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* ObjectFlags.Mapped value when the public ts.ObjectFlags is missing (e.g. older or different TS build).
|
|
124
|
+
* Used only as a fallback for mapped-type detection in serialization.
|
|
125
|
+
* Value 32 corresponds to ObjectFlags.Mapped in TypeScript 5.x; may need adjustment for other versions.
|
|
126
|
+
*/
|
|
127
|
+
export const FALLBACK_OBJECT_FLAGS_MAPPED = 32;
|