import fs from 'node:fs';
import { createRequire, isBuiltin } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import { FileReadError } from './error';
import { createImportMapResolver } from './import-map-resolve';
import type { ImportMap } from './types';
const requireSync = createRequire(import.meta.url);
/**
* Cache value type. Style-asset imports (`*.css`, `*.scss`, etc.) materialize
* as a `SyntheticModule` exposing the asset URL; everything else parses through
* `SourceTextModule`. Both inherit from the (internal) base `vm.Module` and
* expose the `namespace` getter the public API ultimately returns.
*/
type CachedVmModule = vm.SourceTextModule | vm.SyntheticModule;
/**
* Extensions that the esmx federation contract treats as STYLE ASSETS rather
* than executable JS modules. On the server they materialize as no-op synthetic
* modules exposing the asset URL — they have no observable side effect there,
* because there is no document. Browsers receive the stylesheet via a
* `` that the host injects from the federation manifest
* (`chunks[*].css[]`), not by evaluating these modules. See
* `docs/design/principles.md` P1 and the G section of the redesign plan.
*
* Query strings (`?inline`, `?url`) are tolerated — bundlers sometimes attach
* them when rewriting CSS imports.
*/
const STYLE_ASSET_RE =
/\.(?:css|scss|sass|less|stylus|styl|pcss|postcss)(?:\?.*)?$/i;
function isStyleAsset(url: string): boolean {
return STYLE_ASSET_RE.test(url);
}
let lazyGlobalsMaterialized = false;
/**
* Materializes Node's lazily-defined `DOMException` global in the main realm
* before a vm SSR context is created.
*
* Node installs `DOMException` as a lazy getter on the global object. When that
* getter is first invoked from inside a vm context — which happens when an SSR
* library copies/enumerates `globalThis` during rendering (e.g. `@lit-labs/ssr`
* runs `Object.assign(globalThis, window)`) — Node hits an `isolate_data`
* native assertion and aborts the entire process, because vm contexts do not
* carry Node's per-context data.
*
* This is a robustness property of esmx's own vm execution sandbox, not a shim
* for any specific framework: reading the global here, in the main realm,
* replaces the lazy getter with a plain data property on the shared global so
* code running inside the vm only ever observes a concrete value. It runs once
* and has no observable effect beyond eagerly instantiating a global that would
* otherwise be created on first access anyway.
*/
function materializeLazyGlobals(): void {
if (lazyGlobalsMaterialized) {
return;
}
lazyGlobalsMaterialized = true;
void globalThis.DOMException;
}
export interface VmImportOptions {
readFileSync?: (path: string) => string;
}
async function importBuiltinModule(specifier: string, context: vm.Context) {
const nodeModule = await import(specifier);
const keys = Object.keys(nodeModule);
const module = new vm.SyntheticModule(
keys,
function evaluateCallback() {
keys.forEach((key) => {
this.setExport(key, nodeModule[key]);
});
},
{
identifier: specifier,
context: context
}
);
await module.link(() => {
throw new TypeError(`Native modules should not be linked`);
});
await module.evaluate();
return module;
}
export function createVmImport(
baseURL: URL,
importMap: ImportMap = {},
options?: VmImportOptions
) {
const readFileSync =
options?.readFileSync ?? ((path) => fs.readFileSync(path, 'utf-8'));
const importMapResolver = createImportMapResolver(baseURL.href, importMap);
const buildMeta = (specifier: string, parent: string): ImportMeta => {
const result = importMapResolver(specifier, parent);
const url: string = result ?? import.meta.resolve(specifier, parent);
const filename = fileURLToPath(url);
return {
main: false,
filename,
dirname: path.dirname(filename),
url,
resolve: (specifier: string, parent: string | URL = url) => {
return import.meta.resolve(specifier, parent);
}
};
};
function syncLinker(
specifier: string,
parent: string,
context: vm.Context,
cache: Map,
linkStatus: Map>,
moduleIds: string[]
): CachedVmModule {
if (isBuiltin(specifier)) {
const nodeModule = requireSync(specifier);
const keys = Object.keys(nodeModule);
const hasDefault = keys.includes('default');
if (!hasDefault) {
keys.push('default');
}
const module = new vm.SyntheticModule(
keys,
function evaluateCallback() {
keys.forEach((key) => {
this.setExport(
key,
key === 'default' && !hasDefault
? nodeModule
: nodeModule[key]
);
});
},
{
identifier: specifier,
context: context
}
);
const linkResult = module.link(() => {
throw new TypeError(`Native modules should not be linked`);
});
const linkPromise =
linkResult && typeof linkResult.then === 'function'
? linkResult.then(() => module.evaluate())
: module.evaluate();
linkStatus.set(specifier, linkPromise);
return module;
}
const meta = buildMeta(specifier, parent);
const cachedModule = cache.get(meta.url);
if (cachedModule) {
return cachedModule;
}
// Style assets (.css and friends) are part of esmx's federation
// contract but carry no observable server-side behaviour — the host
// emits a `` from the manifest, not by
// evaluating the file. Return a SyntheticModule exposing the asset
// URL so user code that writes `import sheet from './x.css'`
// typechecks and runs without the VM trying to parse CSS as JS.
if (isStyleAsset(meta.url)) {
const exportNames = ['default', 'href'];
const styleModule = new vm.SyntheticModule(
exportNames,
function evaluateCallback() {
this.setExport('default', meta.url);
this.setExport('href', meta.url);
},
{ identifier: specifier, context }
);
cache.set(meta.url, styleModule);
const linkResult = styleModule.link(() => {
throw new TypeError(
`Style modules should not be linked: ${specifier}`
);
});
const linkPromise =
linkResult && typeof linkResult.then === 'function'
? linkResult.then(() => styleModule.evaluate())
: styleModule.evaluate();
linkStatus.set(meta.url, linkPromise);
return styleModule;
}
let text: string;
try {
text = readFileSync(meta.filename);
} catch (error) {
throw new FileReadError(
`Failed to read module: ${meta.filename}`,
moduleIds,
meta.filename,
error as Error
);
}
const module = new vm.SourceTextModule(text, {
initializeImportMeta: (importMeta) => {
Object.assign(importMeta, meta);
},
identifier: specifier,
context: context,
importModuleDynamically: (specifier, referrer) => {
return moduleLinker(
specifier,
meta.url,
referrer.context,
cache,
linkStatus,
[...moduleIds, meta.filename]
);
}
});
cache.set(meta.url, module);
const linkPromise = module
.link((specifier: string, referrer) => {
return syncLinker(
specifier,
meta.url,
referrer.context,
cache,
linkStatus,
[...moduleIds, meta.filename]
);
})
.then(() => module.evaluate());
linkStatus.set(meta.url, linkPromise);
return module;
}
async function moduleLinker(
specifier: string,
parent: string,
context: vm.Context,
cache: Map,
linkStatus: Map>,
moduleIds: string[]
) {
if (isBuiltin(specifier)) {
return importBuiltinModule(specifier, context);
}
const meta = buildMeta(specifier, parent);
const cachedModule = cache.get(meta.url);
if (cachedModule) {
const status = linkStatus.get(meta.url);
if (status) {
await status;
}
return cachedModule;
}
const module = syncLinker(
specifier,
parent,
context,
cache,
linkStatus,
moduleIds
);
const status = linkStatus.get(meta.url);
if (status) {
await status;
}
return module;
}
return async (
specifier: string,
parent: string,
sandbox?: vm.Context,
options?: vm.CreateContextOptions
) => {
materializeLazyGlobals();
const context = vm.createContext(sandbox, options);
const cache = new Map();
const linkStatus = new Map>();
const module = await moduleLinker(
specifier,
parent,
context,
cache,
linkStatus,
[]
);
return module.namespace as Record;
};
}