api: merge module interceptors for esm and node

This commit is contained in:
Johannes
2026-04-30 15:14:52 +02:00
parent d906bcf93d
commit 41f67815ee
2 changed files with 69 additions and 31 deletions

View File

@@ -0,0 +1,50 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
suite('vscode API - Module Interception', () => {
test('import and require(vscode) return the same API instance in ESM', async function () {
// This file CANNOT be written to the OS temp directory.
// The VS Code API module interceptor looks up the extension by associating
// the parent URL via path containment. If the file is placed outside the
// extension's directory, the interceptor will fail to provide the 'vscode' module.
const testFile = path.join(__dirname, 'esm-test.mjs');
try {
try {
fs.writeFileSync(testFile, `
// THIS IS A TEMPORARY FILE CREATED BY VSCODE-API-TESTS (module.test.ts)
// IT SHOULD BE AUTO-DELETED. IF YOU SEE THIS, IT IS SAFE TO REMOVE.
import * as vscode1 from 'vscode';
import { createRequire } from 'node:module';
import * as assert from 'assert';
export function runTest() {
const vscode2 = createRequire(import.meta.url)('vscode');
assert.ok(Object.keys(vscode1).length > 0);
for (const key of Object.keys(vscode1)) {
assert.strictEqual(vscode1[key], vscode2[key], "Mismatch at " + key);
}
return true;
}
`);
} catch (err) {
this.skip();
}
const asyncImport = new Function('url', 'return import(url)');
const m = await asyncImport(vscode.Uri.file(testFile).toString(true));
assert.strictEqual(m.runTest(), true);
} finally {
try { fs.unlinkSync(testFile); } catch (err) { /* ignore */ }
}
});
});

View File

@@ -23,12 +23,23 @@ import nodeModule from 'node:module';
import { assertType } from '../../../base/common/types.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { BidirectionalMap } from '../../../base/common/map.js';
import { DisposableStore } from '../../../base/common/lifecycle.js';
import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js';
const require = nodeModule.createRequire(import.meta.url);
class NodeModuleRequireInterceptor extends RequireInterceptor {
private static _createDataUri(scriptContent: string): string {
return `data:text/javascript;base64,${Buffer.from(scriptContent).toString('base64')}`;
}
private static _vscodeImportFnName = `_VSCODE_IMPORT_VSCODE_API`;
private readonly _store = new DisposableStore();
dispose(): void {
this._store.dispose();
}
protected _installInterceptor(): void {
const that = this;
const node_module = require('module');
@@ -72,29 +83,12 @@ class NodeModuleRequireInterceptor extends RequireInterceptor {
}
return request;
};
}
}
class NodeModuleInterceptor extends RequireInterceptor {
private static _createDataUri(scriptContent: string): string {
return `data:text/javascript;base64,${Buffer.from(scriptContent).toString('base64')}`;
}
private static _vscodeImportFnName = `_VSCODE_IMPORT_VSCODE_API`;
private readonly _store = new DisposableStore();
dispose(): void {
this._store.dispose();
}
protected override _installInterceptor(): void {
const apiInstances = new BidirectionalMap<typeof vscode, string>();
const apiImportDataUrl = new Map<string, string>();
// define a global function that can be used to get API instances given a random key
Object.defineProperty(globalThis, NodeModuleInterceptor._vscodeImportFnName, {
Object.defineProperty(globalThis, NodeModuleRequireInterceptor._vscodeImportFnName, {
enumerable: false,
configurable: false,
writable: false,
@@ -127,18 +121,17 @@ class NodeModuleInterceptor extends RequireInterceptor {
// Create and cache a data-url which is the import script for the API instance
let scriptDataUrlSrc = apiImportDataUrl.get(key);
if (!scriptDataUrlSrc) {
const jsCode = `const _vscodeInstance = globalThis.${NodeModuleInterceptor._vscodeImportFnName}('${key}');\n\n${Object.keys(apiInstance).map((name => `export const ${name} = _vscodeInstance['${name}'];`)).join('\n')}`;
scriptDataUrlSrc = NodeModuleInterceptor._createDataUri(jsCode);
const jsCode = `const _vscodeInstance = globalThis.${NodeModuleRequireInterceptor._vscodeImportFnName}('${key}');\n\n${Object.keys(apiInstance).map((name => `export const ${name} = _vscodeInstance['${name}'];`)).join('\n')}`;
scriptDataUrlSrc = NodeModuleRequireInterceptor._createDataUri(jsCode);
apiImportDataUrl.set(key, scriptDataUrlSrc);
}
return scriptDataUrlSrc;
};
nodeModule.registerHooks({
const hooks = nodeModule.registerHooks({
resolve: (specifier, context, nextResolve) => {
if (specifier !== 'vscode' || !context.parentURL) {
return nextResolve(specifier, context);
}
console.log('ESM resolve', specifier, context);
const otherUrl = lookup(context.parentURL);
return {
url: otherUrl,
@@ -146,6 +139,7 @@ class NodeModuleInterceptor extends RequireInterceptor {
};
},
});
this._store.add(toDisposable(() => hooks.deregister()));
}
}
@@ -174,15 +168,9 @@ export class ExtHostExtensionService extends AbstractExtHostExtensionService {
// Module loading tricks based on `module._load`.
// `module._load` intercepts `require(...)`.
await this._instaService.createInstance(NodeModuleRequireInterceptor, extensionApiFactory, { mine: this._myRegistry, all: this._globalRegistry })
.install();
// Module loading tricks based on `module.registerHooks`.
// `module.registerHooks` is a generic interceptor that intercepts `require(...)`, `import ...`, and `import(...)`.
// However, at this time, `NodeModuleInterceptor` only intercepts `import 'vscode'` and `import('vscode')`.
// This can also intercept `require('vscode')`, but interception by `NodeModuleRequireInterceptor` takes precedence.
// In the future, we can consider migrating all interception logic to `NodeModuleInterceptor` and removing `NodeModuleRequireInterceptor`.
await this._store.add(this._instaService.createInstance(NodeModuleInterceptor, extensionApiFactory, { mine: this._myRegistry, all: this._globalRegistry }))
await this._store.add(this._instaService.createInstance(NodeModuleRequireInterceptor, extensionApiFactory, { mine: this._myRegistry, all: this._globalRegistry }))
.install();
performance.mark('code/extHost/didInitAPI');