diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/module.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/module.test.ts new file mode 100644 index 00000000000..a556bf7dd96 --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/module.test.ts @@ -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 */ } + } + }); + +}); diff --git a/src/vs/workbench/api/node/extHostExtensionService.ts b/src/vs/workbench/api/node/extHostExtensionService.ts index dac895f46bb..ab221e8aed7 100644 --- a/src/vs/workbench/api/node/extHostExtensionService.ts +++ b/src/vs/workbench/api/node/extHostExtensionService.ts @@ -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(); const apiImportDataUrl = new Map(); // 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');