Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions esbuild.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import esbuild from 'esbuild';
import fs from 'node:fs/promises';
import path from 'node:path';
import { auditBundle } from './tasks/compilation/bundleAudit.mjs';

const production = process.argv.includes('--production');
const watch = process.argv.includes('--watch');
Expand Down Expand Up @@ -42,17 +44,62 @@ const umdEsmLoaderPlugin = {
},
};

/**
* The telemetry package advertises an ESM module but has no exports map, so esbuild's Node
* resolution otherwise selects its CommonJS main entry.
*/
const telemetryEsmLoaderPlugin = {
name: 'telemetryEsmLoaderPlugin',

setup(build) {
build.onResolve({ filter: /^@vscode\/extension-telemetry$/ }, async () => {
const packageDirectory = path.resolve('node_modules/@vscode/extension-telemetry');
const packageMetadata = JSON.parse(
await fs.readFile(path.join(packageDirectory, 'package.json'), 'utf8')
);
if (typeof packageMetadata.module !== 'string') {
throw new Error('@vscode/extension-telemetry no longer declares an ESM module entry.');
}

return { path: path.resolve(packageDirectory, packageMetadata.module) };
});
},
};

const bundleAuditPlugin = {
name: 'bundleAuditPlugin',

setup(build) {
build.onEnd(async (result) => {
if (result.errors.length > 0) {
return;
}

try {
await auditBundle(result.metafile, production);
} catch (error) {
return {
errors: [{ text: error instanceof Error ? error.message : String(error) }],
};
}
});
},
};

async function main() {
const ctx = await esbuild.context({
entryPoints: ['src/main.ts'],
bundle: true,
format: 'esm',
// Bundled CommonJS dependencies still require Node built-ins at runtime. The exact owners
// are enforced by bundleAuditPlugin so this compatibility bridge cannot grow unnoticed.
banner: {
js: [
`import { createRequire } from 'node:module';`,
`const require = createRequire(import.meta.url);`,
].join('\n'),
},
metafile: true,
minify: production,
sourcemap: !production,
sourcesContent: false,
Expand All @@ -62,8 +109,10 @@ async function main() {
logLevel: 'info',
plugins: [
umdEsmLoaderPlugin,
telemetryEsmLoaderPlugin,
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
bundleAuditPlugin,
],
});
if (watch) {
Expand Down
266 changes: 138 additions & 128 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
"dependencies": {
"@github/copilot-language-server": "1.290.0",
"@microsoft/servicehub-framework": "4.2.99-beta",
"@vscode/extension-telemetry": "^0.9.0",
"@vscode/extension-telemetry": "^1.5.2",
"@vscode/js-debug-browsers": "^1.1.0",
"archiver": "5.3.0",
"execa": "4.0.0",
Expand Down
5 changes: 1 addition & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { CsharpChannelObserver } from './shared/observers/csharpChannelObserver.
import { CsharpLoggerObserver } from './shared/observers/csharpLoggerObserver.ts';
import { EventStream } from './eventStream.ts';
import { PlatformInformation } from './shared/platform.ts';
import telemetryReporterModule from '@vscode/extension-telemetry';
import { TelemetryReporter } from '@vscode/extension-telemetry';
import { vscodeNetworkSettingsProvider } from './networkSettings.ts';
import createOptionStream from './shared/observables/createOptionStream.ts';
import { AbsolutePathPackage } from './packageManager/absolutePathPackage.ts';
Expand All @@ -32,9 +32,6 @@ import { checkIsSupportedPlatform } from './checkSupportedPlatform.ts';
import { activateRoslyn } from './activateRoslyn.ts';
import { LimitedActivationStatus } from './shared/limitedActivationStatus.ts';

// The package's CommonJS entry point exposes its constructor through the imported default object's default export.
const TelemetryReporter = telemetryReporterModule.default;

export async function activate(
context: vscode.ExtensionContext
): Promise<CSharpExtensionExports | OmnisharpExtensionExports | LimitedExtensionExports | null> {
Expand Down
4 changes: 2 additions & 2 deletions src/omnisharp/observers/telemetryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ export class TelemetryObserver {
}
case EventType.TelemetryErrorEvent: {
const telemetryErrorEvent = <TelemetryErrorEvent>event;
// errorProps has been ignored by @vscode/extension-telemetry since v0.6.
this.reporter.sendTelemetryErrorEvent(
telemetryErrorEvent.eventName,
telemetryErrorEvent.properties,
telemetryErrorEvent.measures,
telemetryErrorEvent.errorProps
telemetryErrorEvent.measures
);
break;
}
Expand Down
3 changes: 1 addition & 2 deletions src/shared/telemetryReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ export interface ITelemetryReporter {
sendTelemetryErrorEvent(
eventName: string,
properties?: { [key: string]: string },
measures?: { [key: string]: number },
errorProps?: string[]
measures?: { [key: string]: number }
): void;
}

Expand Down
195 changes: 195 additions & 0 deletions tasks/compilation/bundleAudit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import fs from 'node:fs/promises';

const expectedRuntimeRequires = {
'@microsoft/servicehub-framework': ['assert', 'crypto', 'events', 'net', 'os', 'path', 'stream', 'util'],
'@vscode/js-debug-browsers': ['child_process', 'fs', 'os', 'path'],
'@vscode/l10n': ['fs', 'fs/promises'],
'agent-base': ['http', 'https', 'net'],
archiver: ['buffer', 'events', 'fs', 'path', 'stream', 'util', 'zlib'],
'archiver-utils': ['path', 'stream', 'util'],
bl: ['buffer', 'events', 'stream', 'util'],
'buffer-crc32': ['buffer'],
'compress-commons': ['buffer', 'events', 'stream', 'util'],
'crc32-stream': ['buffer', 'events', 'stream', 'util', 'zlib'],
'cross-spawn': ['child_process', 'fs', 'path'],
debug: ['tty', 'util'],
execa: ['child_process', 'os', 'path'],
'fs-constants': ['constants', 'fs'],
'fs-extra': ['path'],
'fs.realpath': ['fs', 'path'],
'get-stream': ['buffer', 'stream'],
glob: ['assert', 'events', 'fs', 'path', 'util'],
'graceful-fs': ['assert', 'constants', 'fs', 'stream', 'util'],
'http-proxy-agent': ['events', 'net', 'tls'],
'https-proxy-agent': ['assert', 'net', 'tls'],
inherits: ['util'],
isexe: ['fs'],
jsonfile: ['fs'],
lazystream: ['util'],
'merge-stream': ['stream'],
// Razor is a version-pinned component tarball. Update it through component servicing, not npm.
'microsoft.aspnetcore.razor.vscode': [
'child_process',
'crypto',
'events',
'fs',
'net',
'os',
'path',
'url',
'util',
'vscode',
],
minimatch: ['path'],
'msgpack-lite': ['stream', 'util'],
'nerdbank-streams': ['crypto', 'events', 'stream'],
'node-machine-id': ['child_process', 'crypto'],
'npm-run-path': ['path'],
'ps-list': ['child_process', 'path', 'util'],
pump: ['fs'],
'readable-stream': ['events', 'stream', 'util'],
'readdir-glob': ['events', 'fs', 'path'],
'safe-buffer': ['buffer'],
'signal-exit': ['assert', 'events'],
'supports-color': ['os', 'tty'],
'tar-stream': ['buffer', 'events', 'stream', 'string_decoder', 'util'],
'util-deprecate': ['util'],
'vscode-jsonrpc': ['crypto', 'fs', 'net', 'os', 'path', 'util'],
'vscode-languageclient': ['child_process', 'fs', 'path', 'readline', 'vscode'],
which: ['path'],
yauzl: ['events', 'fs', 'stream', 'util', 'zlib'],
'zip-stream': ['util'],
};

function getPackageOwner(inputPath) {
const normalizedPath = inputPath.replaceAll('\\', '/');
const marker = 'node_modules/';
const markerIndex = normalizedPath.indexOf(marker);
if (markerIndex < 0) {
return undefined;
}

const [firstSegment, secondSegment] = normalizedPath.slice(markerIndex + marker.length).split('/');
return firstSegment.startsWith('@') ? `${firstSegment}/${secondSegment}` : firstSegment;
}
Comment on lines +69 to +79

function getRuntimeRequires(metafile) {
const packageRequires = new Map();
const firstPartyRequires = [];

for (const [inputPath, input] of Object.entries(metafile.inputs)) {
for (const imported of input.imports) {
if (!imported.external || !['require-call', 'require-resolve'].includes(imported.kind)) {
continue;
}

const packageOwner = getPackageOwner(inputPath);
if (!packageOwner) {
firstPartyRequires.push(`${inputPath} -> ${imported.path}`);
continue;
}

let record = packageRequires.get(packageOwner);
if (!record) {
record = { imports: 0, inputs: new Set(), targets: new Set() };
packageRequires.set(packageOwner, record);
}

record.imports++;
record.inputs.add(inputPath);
record.targets.add(imported.path);
}
}

return { firstPartyRequires, packageRequires };
}

function getDifferences(left, right) {
return [...left].filter((value) => !right.has(value)).sort();
}

function validateRuntimeRequires(firstPartyRequires, packageRequires) {
const issues = [];
if (firstPartyRequires.length > 0) {
issues.push(`First-party source emitted CommonJS runtime requires: ${firstPartyRequires.join(', ')}`);
}

const expectedPackages = new Set(Object.keys(expectedRuntimeRequires));
const actualPackages = new Set(packageRequires.keys());
const unexpectedPackages = getDifferences(actualPackages, expectedPackages);
const stalePackages = getDifferences(expectedPackages, actualPackages);

if (unexpectedPackages.length > 0) {
issues.push(`Review and allowlist new bridge owners: ${unexpectedPackages.join(', ')}`);
}
if (stalePackages.length > 0) {
issues.push(`Remove bridge owners that no longer require it: ${stalePackages.join(', ')}`);
}

for (const packageName of [...expectedPackages].filter((name) => actualPackages.has(name)).sort()) {
const expectedTargets = new Set(expectedRuntimeRequires[packageName]);
const actualTargets = packageRequires.get(packageName).targets;
const addedTargets = getDifferences(actualTargets, expectedTargets);
const removedTargets = getDifferences(expectedTargets, actualTargets);

if (addedTargets.length > 0) {
issues.push(`${packageName} added runtime requires: ${addedTargets.join(', ')}`);
}
if (removedTargets.length > 0) {
issues.push(`${packageName} no longer requires: ${removedTargets.join(', ')}`);
}
}

if (issues.length > 0) {
throw new Error(`Bundle runtime require audit failed:\n- ${issues.join('\n- ')}`);
}
}

function validateEsmOutput(metafile) {
const output = Object.entries(metafile.outputs).find(([outputPath]) =>
outputPath.replaceAll('\\', '/').endsWith('dist/extension.mjs')
)?.[1];
if (!output) {
throw new Error('Bundle runtime require audit could not find dist/extension.mjs in the esbuild metafile.');
}

if (output.exports.length !== 1 || output.exports[0] !== 'activate') {
throw new Error(`Expected the extension bundle to export only activate; found: ${output.exports.join(', ')}`);
}

if (!output.imports.some((imported) => imported.path === 'vscode' && imported.kind === 'import-statement')) {
throw new Error('Expected the extension bundle to use a native ESM import for vscode.');
}
}

export async function auditBundle(metafile, production) {
if (!metafile) {
throw new Error('Bundle runtime require audit requires an esbuild metafile.');
}

const { firstPartyRequires, packageRequires } = getRuntimeRequires(metafile);
validateRuntimeRequires(firstPartyRequires, packageRequires);
validateEsmOutput(metafile);

const packageJson = JSON.parse(await fs.readFile('package.json', 'utf8'));
const directDependencies = new Set(Object.keys(packageJson.dependencies));
const packageNames = [...packageRequires.keys()].sort();
const directOwners = packageNames.filter((name) => directDependencies.has(name));
const transitiveOwners = packageNames.filter((name) => !directDependencies.has(name));
const vscodeOwners = packageNames.filter((name) => packageRequires.get(name).targets.has('vscode'));
const moduleCount = new Set([...packageRequires.values()].flatMap((record) => [...record.inputs])).size;
const importCount = [...packageRequires.values()].reduce((count, record) => count + record.imports, 0);
const mode = production ? 'production' : 'development';

console.log(
`[bundle-audit] ${mode}: ${packageNames.length} owners, ${moduleCount} modules, ${importCount} runtime requires`
);
console.log(`[bundle-audit] direct/component: ${directOwners.join(', ')}`);
console.log(`[bundle-audit] transitive: ${transitiveOwners.join(', ')}`);
console.log(`[bundle-audit] require("vscode"): ${vscodeOwners.join(', ')}`);
}
3 changes: 1 addition & 2 deletions test/fakes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ export const getNullTelemetryReporter = (): ITelemetryReporter => {
sendTelemetryErrorEvent: (
_eventName: string,
_properties?: { [key: string]: string },
_measures?: { [key: string]: number },
_errorProps?: string[]
_measures?: { [key: string]: number }
) => {
/** empty */
},
Expand Down
43 changes: 43 additions & 0 deletions test/lsptoolshost/artifactTests/extensionBundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { describe, expect, test } from '@jest/globals';
import esbuild from 'esbuild';
import fs from 'fs-extra';
import path from 'node:path';

const packageJson = fs.readJsonSync(path.resolve('package.json'));
const extensionEntry = path.resolve(packageJson.main);

describe('Extension bundle', () => {
test('package main points to an existing .mjs file', async () => {
expect(path.extname(packageJson.main)).toBe('.mjs');
await expect(fs.pathExists(extensionEntry)).resolves.toBe(true);
});

test('entry is native ESM with the activation contract', async () => {
const result = await esbuild.build({
entryPoints: [extensionEntry],
bundle: false,
format: 'esm',
metafile: true,
outdir: 'artifact-audit',
write: false,
});
const output = Object.values(result.metafile.outputs)[0];

expect(output.exports).toEqual(['activate']);
expect(output.imports).toContainEqual({
external: true,
kind: 'import-statement',
path: 'vscode',
});
});

test('JavaScript signing includes .mjs output', async () => {
const signingProject = await fs.readFile(path.resolve('msbuild/signing/signJs/signJs.proj'), 'utf8');
expect(signingProject).toContain('<FilesToSign Include="$(OutDir)*.mjs">');
});
});
Loading
Loading