-
Notifications
You must be signed in to change notification settings - Fork 880
/
module-loader.ts
309 lines (281 loc) · 9.37 KB
/
module-loader.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
import * as path from 'path';
import {promises as fs} from 'fs';
import {URL, fileURLToPath, pathToFileURL} from 'url';
import * as vm from 'vm';
import enhancedResolve from 'enhanced-resolve';
import {builtinModules} from 'module';
const builtIns = new Set(builtinModules);
const specifierMatches = (specifier: string, match: string) =>
specifier === match || specifier.startsWith(match + '/');
// IMPORTANT: We should always use our own VmModule interface for public APIs
// instead of vm.Module, because vm.Module typings are not provided by
// @types/node, and we do not augment them in a way that affects consumers (the
// types in custom_typings are only available during our own build).
/**
* A subset of the Node vm.Module API.
*/
export interface VmModule {
/**
* The namespace object of the module that provides access to its exports.
* See https://nodejs.org/api/vm.html#modulenamespace
*/
namespace: {[name: string]: unknown};
}
export interface ModuleRecord {
path: string;
module?: VmModule;
imports: Array<string>;
evaluated: Promise<VmModule>;
}
interface ImportResult {
path: string;
module: VmModule;
}
export interface Options {
global?: object;
filesystem?: FileSystem;
}
/**
* A JavaScript module loader that utilizes the Node `vm` module
* (https://nodejs.org/api/vm.html).
*
* Most of the hooks implement fairly standard web-compatible module loading:
* - An import specifier resolver that uses Node module resoution
* - A linker that loads dependencies from the local filesystem
* - A module cache keyed by resolved URL
* - import.meta.url support
* - Dynamic import() that functions the same as static imports
*
* There are some behaviors specific to lit-html. Mainly that imports of certain
* directives are redirected to Node/SSR compatible implementations.
*/
export class ModuleLoader {
private static _nextVmContextId = 0;
// This ID is appended to all module identifiers to work around an apparent
// v8 bug where duplicate identifiers would cause a crash.
private readonly _vmContextId = ModuleLoader._nextVmContextId++;
private readonly _context: vm.Context;
/**
* TODO (justinfagnani): This is a temporary stand-in for a real graph API.
* We want to be able to invalidate a module and the transitive closure
* of its importers so that we can update the graph.
*
* The keys of the map are useful for enumering static imported modules
* after an entrypoint is loaded.
*/
readonly cache = new Map<string, ModuleRecord>();
// TODO (justinfagnani): Allow passing a filesystem object to allow network
// sources, in-memory for tests, etc.
constructor(options?: Options) {
this._context = vm.createContext(options?.global);
}
/**
* Imports a module given by `path` into a new VM context with `contextGlobal` as the
* global object.
*/
async importModule(
specifier: string,
referrerPathOrFileUrl: string
): Promise<ImportResult> {
const referrerPath = referrerPathOrFileUrl.startsWith('file://')
? fileURLToPath(referrerPathOrFileUrl)
: referrerPathOrFileUrl;
const result = await this._loadModule(specifier, referrerPath);
const module = result.module as vm.Module;
if (module.status === 'unlinked') {
await module.link(this._linker);
}
if (module.status !== 'evaluated') {
await module.evaluate();
}
return result;
}
/**
* Performs the actual loading of module source from disk, creates the
* Module instance, and maintains the module cache.
*
* Used directly by `importModule` and by the linker and dynamic import()
* support function.
*/
private async _loadModule(
specifier: string,
referrerPath: string
): Promise<ImportResult> {
if (builtIns.has(specifier)) {
return this._loadBuiltInModule(specifier);
}
const moduleURL = await resolveSpecifier(specifier, referrerPath);
if (moduleURL.protocol !== 'file:') {
throw new Error(`Unsupported protocol: ${moduleURL.protocol}`);
}
const modulePath = fileURLToPath(moduleURL);
// Look in the cache
let moduleRecord = this.cache.get(modulePath);
if (moduleRecord !== undefined) {
return {
path: modulePath,
module: await moduleRecord.evaluated,
};
}
const modulePromise = (async () => {
const source = await fs.readFile(modulePath, 'utf-8');
// TODO: store and re-use cachedData:
// https://nodejs.org/api/vm.html#sourcetextmodulecreatecacheddata
return new vm.SourceTextModule(source, {
initializeImportMeta,
importModuleDynamically: this._importModuleDynamically,
context: this._context,
identifier: this._getIdentifier(modulePath),
});
})();
moduleRecord = {
path: modulePath,
imports: [],
evaluated: modulePromise,
};
this.cache.set(modulePath, moduleRecord);
const module = await modulePromise;
return {
path: modulePath,
module,
};
}
private async _loadBuiltInModule(specifier: string): Promise<ImportResult> {
let moduleRecord = this.cache.get(specifier);
if (moduleRecord !== undefined) {
return {
path: specifier,
module: await moduleRecord.evaluated,
};
}
// Provide basic support for built-in modules (needed for node shims of
// DOM APIs like fetch)
const modulePromise = (async () => {
const mod = await import(specifier);
return new vm.SyntheticModule(
Object.keys(mod),
function () {
Object.keys(mod).forEach((key) => this.setExport(key, mod[key]));
},
{
context: this._context,
identifier: this._getBuiltInIdentifier(specifier),
}
);
})();
moduleRecord = {
path: specifier,
// TODO (justinfagnani): these imports should be populated in the linker
// to record the edges of the module graph
imports: [],
evaluated: modulePromise,
};
this.cache.set(specifier, moduleRecord);
const module = await modulePromise;
return {
path: specifier,
module,
};
}
private _importModuleDynamically = async (
specifier: string,
referencingModule: vm.Module
): Promise<vm.Module> => {
const result = await this.importModule(
specifier,
referencingModule.identifier
);
return result.module as vm.Module;
};
private _linker = async (
specifier: string,
referencingModule: vm.Module
): Promise<vm.Module> => {
const {identifier} = referencingModule;
if (!/:\d+$/.test(identifier)) {
throw new Error('Unexpected file:// URL identifier without context ID');
}
const referrerPath = identifier.split(/:\d+$/)[0];
const result = await this._loadModule(specifier, referrerPath);
const referrerModule = this.cache.get(referrerPath);
if (referrerModule !== undefined) {
referrerModule.imports.push(result.path);
}
return result.module as vm.Module;
};
private _getIdentifier(modulePath: string) {
return `${modulePath}:${this._vmContextId}`;
}
private _getBuiltInIdentifier(specifier: string) {
return `${specifier}:${this._vmContextId}`;
}
}
/**
* Resolves specifiers using web-ish Node module resolution. Web-compatible full
* URLs are passed through unmodified. Relative and absolute URLs (starting in
* `/`, `./`, `../`) are resolved relative to `referrerPath`. "Bare" module
* specifiers are resolved with the 'resolve' package.
*
* This replaces some Lit modules with SSR compatible equivalents. This is
* currently hard-coded, but should instead be done with a configuration object.
*/
export const resolveSpecifier = async (
specifier: string,
referrerPath: string
): Promise<URL> => {
try {
// First see if the specifier is a full URL, and if so, use that.
// TODO: This will mainly be http:// and https:// URLs, which we may _not_
// want to support. We probably also want to filter out file:// URLs as
// those will be absolute to the file system.
return new URL(specifier);
} catch (e) {
if (referrerPath === undefined) {
throw new Error('referrerPath is undefined');
}
if (
specifierMatches(specifier, 'lit') ||
specifierMatches(specifier, 'lit-html') ||
specifierMatches(specifier, 'lit-element') ||
specifierMatches(specifier, '@lit/reactive-element')
) {
// Override where we resolve lit packages from so that we always resolve to
// a single version.
referrerPath = fileURLToPath(import.meta.url);
}
const modulePath = await resolve(specifier, path.dirname(referrerPath), {
modules: ['node_modules'],
extensions: ['.js'],
mainFields: ['module', 'jsnext:main', 'main'],
conditionNames: ['node'],
});
return pathToFileURL(modulePath);
}
};
/**
* Web-like import.meta initializer that sets up import.meta.url
*/
const initializeImportMeta = (meta: {url: string}, module: vm.Module) => {
meta.url = module.identifier;
};
const resolve = async (
id: string,
path: string,
opts: Partial<enhancedResolve.ResolveOptions>
): Promise<string> => {
const resolver = enhancedResolve.create(opts);
return new Promise((res, rej) => {
resolver({}, path, id, {}, (err: unknown, resolved?: string) => {
if (err != null) {
rej(err);
} else {
res(resolved!);
}
});
});
};