-
Notifications
You must be signed in to change notification settings - Fork 22
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix and test loading ESM providers in VS Code
- Use a hacky pure-JS converter from ESM to CommonJS instead of the esbuild approach, which was very heavyweight due to its use of wasm and was hard for consumers of @openctx/vscode-lib to use. - Refactor how clients can override imports.
- Loading branch information
Showing
31 changed files
with
738 additions
and
439 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,6 +10,6 @@ | |
"rxjs": "^7.8.1" | ||
}, | ||
"devDependencies": { | ||
"esbuild": "^0.19.11" | ||
"esbuild": "^0.21.3" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,27 +1,11 @@ | ||
import type { Annotation, Item, ItemsParams } from '@openctx/client' | ||
import type * as vscode from 'vscode' | ||
import type { Controller } from './controller.js' | ||
|
||
/** | ||
* The API exposed for this VS Code extension's tests. | ||
*/ | ||
export interface ExtensionApiForTesting { | ||
/** | ||
* Get OpenCtx items for the document. | ||
*/ | ||
getItems(params: ItemsParams): Promise<Item[] | null> | ||
|
||
/** | ||
* Get OpenCtx annotations for the document. | ||
*/ | ||
getAnnotations( | ||
doc: Pick<vscode.TextDocument, 'uri' | 'getText'> | ||
): Promise<Annotation<vscode.Range>[] | null> | ||
} | ||
export interface ExtensionApiForTesting | ||
extends Pick<Controller, 'capabilities' | 'items' | 'annotations'> {} | ||
|
||
export function createApiForTesting(controller: Controller): ExtensionApiForTesting { | ||
return { | ||
getItems: params => controller.items(params), | ||
getAnnotations: doc => controller.annotations(doc), | ||
} | ||
return controller | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
import { describe, expect, test } from 'vitest' | ||
import { esmToCommonJS } from './esmToCommonJS.js' | ||
|
||
describe('esmToCommonJS', () => { | ||
test('default import statements', () => { | ||
const esm = "import foo from './foo.js'" | ||
const expected = "const foo = require('./foo.js');" | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('named import statements', () => { | ||
const esm = "import { foo, bar } from './foobar.js'" | ||
const expected = "const { foo, bar } = require('./foobar.js');" | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('namespace import statements', () => { | ||
const esm = "import * as foobar from './foobar.js'" | ||
const expected = "const foobar = require('./foobar.js');" | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('default export identifiers', () => { | ||
const esm = 'export default foo' | ||
const expected = 'module.exports = foo' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('default export literals', () => { | ||
const esm = 'export default { a: 123 }' | ||
const expected = 'module.exports = { a: 123 }' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('default export multi-line literals', () => { | ||
const esm = 'export default { a: 123\nb: 456 }' | ||
const expected = 'module.exports = { a: 123\nb: 456 }' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('named export statements', () => { | ||
const esm = 'export { foo, bar }' | ||
const expected = 'module.exports = { foo, bar };' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('named export statements with "as" keyword', () => { | ||
const esm = 'export { foo as default }' | ||
const expected = 'module.exports = foo;' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('export const statements', () => { | ||
const esm = 'export const foo = 123;' | ||
const expected = 'exports.foo = 123;' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('export function statements', () => { | ||
const esm = 'export function foo(bar) {' | ||
const expected = 'exports.foo = function foo(bar) {' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('export class statements', () => { | ||
const esm = 'export class Foo {' | ||
const expected = 'exports.Foo = class Foo {' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('minified code 1', () => { | ||
const esm = 'var s=1;export{s as default}' | ||
const expected = 'var s=1;module.exports = s;' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('minified code 2', () => { | ||
const esm = 'function(){}export{s as default}' | ||
const expected = 'function(){}module.exports = s;' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
|
||
test('minified code 3', () => { | ||
const esm = 'export{x as y,s as default}' | ||
const expected = 'module.exports = s;' | ||
expect(esmToCommonJS(esm)).toBe(expected) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
/** | ||
* Convert from ESM JavaScript to CommonJS. | ||
* | ||
* NOTE(sqs): This is hacky! I hope VS Code starts supporting ESM import() in VS Code extensions | ||
* soon so we can get rid of this! See https://github.com/microsoft/vscode/issues/130367. | ||
* | ||
* @param esm An ESM JavaScript string, assumed to have no import() calls. | ||
*/ | ||
export function esmToCommonJS(esm: string): string { | ||
// Convert import statements. | ||
let cjs = esm.replace(/(?<=^|\b)import\s+(\w+)\s+from\s+['"](.+)['"]/gm, "const $1 = require('$2');") | ||
cjs = cjs.replace( | ||
/(?<=^|\b)import\s*\{\s*([\w\s,]+)\s*\}\s+from\s+['"](.+)['"]/gm, | ||
"const { $1} = require('$2');" | ||
) | ||
cjs = cjs.replace( | ||
/(?<=^|\b)import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+)['"]/gm, | ||
"const $1 = require('$2');" | ||
) | ||
|
||
// Convert export default statements. | ||
cjs = cjs.replace(/(?<=^|\b)export\s+default\s+/gm, 'module.exports = ') | ||
|
||
// Convert named export statements. | ||
cjs = cjs.replace( | ||
/(?<=^|\b)export\s*\{\s*(?:[^},]*,\s*)*([\w\s,]+) as default\s*\}/gm, | ||
'module.exports = $1;' | ||
) | ||
cjs = cjs.replace(/(?<=^|\b)export\s*\{\s*([\w\s,]+)\s*\}/gm, 'module.exports = { $1};') | ||
cjs = cjs.replace(/(?<=^|\b)export\s+const\s+(\w+)\s*=\s*(.+);/gm, 'exports.$1 = $2;') | ||
cjs = cjs.replace( | ||
/(?<=^|\b)export\s+function\s+(\w+)\s*\((.*)\)\s*\{/gm, | ||
'exports.$1 = function $1($2) {' | ||
) | ||
cjs = cjs.replace(/(?<=^|\b)export\s+class\s+(\w+)\s*\{/gm, 'exports.$1 = class $1 {') | ||
|
||
return cjs | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { describe, expect, test, vi } from 'vitest' | ||
import { importESMFromString } from './importHelpers.js' | ||
|
||
vi.mock('vscode', () => ({ | ||
env: { uiKind: 1 }, | ||
UIKind: { Desktop: 1 }, | ||
})) | ||
|
||
describe('importESMFromString', () => { | ||
test('works', async () => | ||
expect({ ...((await importESMFromString('export default 123')) as any) }).toEqual({ | ||
default: 123, | ||
})) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import { readFile } from 'node:fs/promises' | ||
import { type Provider, fetchProviderSource } from '@openctx/client' | ||
import * as vscode from 'vscode' | ||
import { esmToCommonJS } from './esmToCommonJS.js' | ||
|
||
export async function importProvider(providerUri: string): Promise<{ default: Provider }> { | ||
const url = new URL(providerUri) | ||
|
||
let source: string | ||
if (url.protocol === 'file:') { | ||
source = await readFile(url.pathname, 'utf-8') | ||
} else { | ||
source = await fetchProviderSource(providerUri) | ||
} | ||
|
||
if (vscode.env.uiKind === vscode.UIKind.Desktop) { | ||
// VS Code Desktop only supports require()ing of CommonJS modules. | ||
return importCommonJSFromESM(source) as { default: Provider } | ||
} | ||
|
||
// VS Code Web supports import()ing, but not cross-origin. | ||
return (await importESMFromString(source)) as { default: Provider } | ||
} | ||
|
||
/** | ||
* Convert an ESM bundle to CommonJS. | ||
* | ||
* VS Code does not support dynamically import()ing ES modules (see | ||
* https://github.com/microsoft/vscode/issues/130367). But we always want OpenCtx providers to be ES | ||
* modules for consistency. So, we need to rewrite the ESM bundle to CommonJS to import it here. | ||
* | ||
* Note that it's deceiving because VS Code using extensionDevelopmentPath *does* support dynamic | ||
* import()s, but they fail when installing the extension from a `.vsix` (including from the | ||
* extension marketplace). When VS Code supports dynamic import()s for extensions, we can remove | ||
* this. | ||
*/ | ||
function importCommonJSFromESM(esmSource: string): unknown { | ||
return { | ||
default: requireCommonJSFromString('<CJS-STRING>', esmToCommonJS(esmSource)), | ||
} | ||
} | ||
|
||
export function requireCommonJSFromString(filename: string, cjsSource: string): unknown { | ||
const Module: any = module.constructor | ||
const m = new Module() | ||
m._compile(cjsSource, filename) | ||
return m.exports | ||
} | ||
|
||
/** | ||
* VS Code Web requires this because it blocks import()s of remote URLs, so we need to fetch() the | ||
* source first and then import it by string. | ||
*/ | ||
export function importESMFromString(esmSource: string): Promise<unknown> { | ||
const url = `data:text/javascript;charset=utf-8;base64,${base64Encode(esmSource)}` | ||
return import(/* @vite-ignore */ url) | ||
} | ||
|
||
/** | ||
* See https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem for why we need | ||
* something other than just `btoa` for base64 encoding. | ||
*/ | ||
function base64Encode(text: string): string { | ||
const bytes = new TextEncoder().encode(text) | ||
const binString = String.fromCodePoint(...bytes) | ||
return btoa(binString) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,5 +9,4 @@ node_modules/ | |
tsconfig.json | ||
test/ | ||
*.tsbuildinfo | ||
dev/** | ||
!node_modules/esbuild-wasm/esbuild.wasm | ||
dev/** |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.