Skip to content

[api] Expose API client modules from VS Code extension - #64647

Open
Andrew Branch (andrewbranch) wants to merge 9 commits into
microsoft:mainfrom
andrewbranch:api-from-vscode-extension
Open

Andrew Branch (andrewbranch) wants to merge 9 commits into
microsoft:mainfrom
andrewbranch:api-from-vscode-extension

Conversation

@andrewbranch

@andrewbranch Andrew Branch (andrewbranch) commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Our VS Code extension already exposes a way for third-party extensions to connect a TypeScript API client to the user's currently running TypeScript LSP server. But since the API client and server need to be version-matched, and users can choose between a workspace version of TypeScript or one of potentially multiple built-in versions for their LSP server, the VS Code extension really needs to give those third-party extensions a way to resolve the correct version of the API client to use.

Previously, third-party extensions would do something like this:

import { API } from "typescript/async"; // <-- extension dependency version!

export function activate(context: vscode.ExtensionContext) {
  const extension = vscode.extensions.getExtension<ExtensionAPI>("TypeScriptTeam.native-preview")!;
  const extensionAPI = await extension.activate();
  const listener = extensionAPI.onLanguageServerInitialized(async () => {
    // ⚠️ *should* only do this inside onLanguageServerInitialized!
    const pipe = await extensionAPI.initializeAPIConnection();
    // 🚨 Establish potentially version-mismatched client/server connection!
    const api = await API.fromLSPConnection({ pipe });
  });
  context.subscriptions.push(listener);
}

With this PR, that will change to:

import type { ExtensionAPI } from "typescript/vscode"; // <-- devDependency for types

export function activate(context: vscode.ExtensionContext) {
  const extension = vscode.extensions.getExtension<ExtensionAPI>("TypeScriptTeam.native-preview")!;
  const extensionAPI = await extension.activate();
  const listener = extensionAPI.onLanguageServerInitialized(async tssdk => {
    // ✅ *can* only do this inside onLanguageServerInitialized
    const pipe = await tssdk.initializeAPIConnection();
    // ✅ Get the client that shipped with the server
    console.log("Resolved TypeScript client version ", tssdk.version);
    const { API } = await tssdk.importModule("typescript/async");
    const api = await API.fromLSPConnection({ pipe });
  });
  context.subscriptions.push(listener);
}

Of course, this means that your API client types are effectively a devDependency representing a peerDependency whose versions could be mismatched at runtime. Extensions should type check and test against multiple versions, and use runtime probes to conditionally access newer client features:

if (api.newMethodAddedInTypeScript7_4) {
  const res = await api.newMethodAddedInTypeScript7_4();
}

More guidance on version compatibility will be documented soon.

Doing the same thing from a third-party LSP server

If you're launching an LSP server that connects to the TypeScript API, the method above won't help you, since you can't access the extension API from another process. For convenience, the same typed module loader is exported from "typescript/vscode". If you're bundling your extension, you can import from that module without bundling in the actual API client:

// extension.ts
import type { ExtensionAPI } from "typescript/vscode";

export async function activate(context: vscode.ExtensionContext) {
  const extension = vscode.extensions.getExtension<ExtensionAPI>("TypeScriptTeam.native-preview")!;
  const extensionAPI = await extension.activate();
  const listener = extensionAPI.onLanguageServerInitialized(async sdk => {
    const client = new LanguageClient("my-server", "My Server", serverOptions, {
      initializationOptions: {
        typescriptPackageJsonPath: sdk.packageJsonPath!,
        typescriptAPIPipe: await sdk.initializeAPIConnection(),
      },
    });
    await client.start();
  });
  context.subscriptions.push(listener);
});
// server.ts
import { createTypeScriptModuleLoader } from "typescript/vscode";

connection.onInitialize(async params => {
    const { typescriptPackageJsonPath, typescriptAPIPipe } =
        params.initializationOptions;

    const loader = createTypeScriptModuleLoader(typescriptPackageJsonPath);
    const { API } = await loader.importModule("typescript/async");
    api = await API.fromLSPConnection({ pipe: typescriptAPIPipe });

    return { capabilities: {} };
});

VSIX structure

This changes VSIX packaging to include node_modules containing typescript and @typescript/typescript-${os}-${arch} instead of the bare tsc executable in lib/.

Manually tested the LSP + an API connection with the packed VSIX, that plus the nightly VSIX, and local dev, but extra eyes on the output are appreciated.

Copilot AI balanced review requested due to automatic review settings October 5, 2026 19:21
@typescript-automation typescript-automation Bot added Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Oct 5, 2026
Comment thread packages/typescript/src/vscode/extensionApi.ts Outdated
}

/** The selected TypeScript installation, bound to one language server initialization. */
export interface TypeScriptSDK {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type names also valid for bikeshedding

export interface ExtensionAPI {
onLanguageServerInitialized: Event<void>;
export interface APIModules {
[exportPath: string]: unknown;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted to have a fallback here so you could use a newer import path than your types expose, but I'm not sure if there's much reason not to just install the latest types as soon as you want to use the latest features. Would it be better to remove this?

This comment was marked as resolved.

@andrewbranch
Andrew Branch (andrewbranch) marked this pull request as draft October 5, 2026 20:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Package-change detection can permit unverifiable versions and abort restarts when a manifest becomes unreadable.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
Resolved since last review (4)

Comment thread packages/vscode-typescript/src/tsdkPackage.ts
Comment thread packages/vscode-typescript/src/tsdkPackage.ts
}
const require = createRequire(manifestPath);
const modulePath = require.resolve(`${manifest.name}/${exportPath.slice("typescript/".length)}`);
return import(pathToFileURL(modulePath).href);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume we're stuck not using import.meta.resolve? Or someone's shim for it? Seems odd to pull out require just for this

Comment thread Herebyfile.mjs
name: "vscode-typescript:pack",
hiddenFromTaskList: true,
dependencies: options.forRelease || usePublishedPlatformPackagesForVsix ? undefined : [buildNativePreviewPackages, cleanSignTempDirectory],
dependencies: options.forRelease || usePublishedPlatformPackagesForVsix ? undefined : [packNativePreviewPackages],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where's the clean go?

return { path: exe, version: "(local)", isLocal: true };
return {
path: exe,
version: "(local)",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is preexisting but now that we don't say tsgo (local), this just says (local) which always seemed wrong to me in the UI 😄

Comment thread Herebyfile.mjs
Comment on lines +2874 to +2878
packageJson.dependencies = {
typescript: embeddedTypeScriptPackageJson.name === "typescript"
? embeddedTypeScriptPackageJson.version
: `npm:${embeddedTypeScriptPackageJson.name}@${embeddedTypeScriptPackageJson.version}`,
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have to set this? Just so we trick vsce?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

3 participants