Skip to content

New IPC API client features#2813

Open
andrewbranch wants to merge 12 commits intomicrosoft:mainfrom
andrewbranch:api-updates
Open

New IPC API client features#2813
andrewbranch wants to merge 12 commits intomicrosoft:mainfrom
andrewbranch:api-updates

Conversation

@andrewbranch
Copy link
Member

@andrewbranch andrewbranch commented Feb 18, 2026

This PR adds several features to the @typescript/api API client package, along with some Go support code. Please note the API is still early and empty, but this is a big step towards making it usable—hopefully the next PR can (finally) be mostly additional API surface, not support infrastructure. From big to small, the changes worth mentioning:

Client-side Snapshot model, state updates, and object lifecycles

The API client's types now reflect a more accurate model of the server: all state is stored in an immutable Snapshot. Before:

const project = await api.loadProject("/src/tsconfig.json");
// ...time goes by...
// Mutate the project:
await project.reloadData();

After:

const snapshot = await api.updateSnapshot({ openProject: "/src/tsconfig.json" });
const project = snapshot.getProject("/src/tsconfig.json");
// ...time goes by...
// Get a new snapshot, assuming anything could have changed.
// Files will be re-read but ASTs reused if they haven't changed:
const newSnapshot = await api.updateSnapshot({ fileChanges: { invalidateAll: true } });
// Or, tell the server which files to invalidate, perhaps if you have your own watcher:
const newSnapshot = await api.updateSnapshot({ fileChanges: { changed: ["/src/file.ts"] } });
// Or, if your API instance is connected to the LSP server, just fast-forward to the LSP state:
const newSnapshot = await api.updateSnapshot();

Previously, Types and Symbols were disposable and had to be explicitly disposed when you no longer needed them. Now, their lifecycles, along with Projects' lifecycles, are tied to the Snapshot. Types and Symbols are no longer disposable, but Snapshots are. Within a single Snapshot, Projects, Types, and Symbols maintain referential equality across multiple calls, but across Snapshots, they are always different objects, even if the server-side data is unchanged.

SourceFiles, on the other hand, are cached at a higher level than the Snapshot since they're more expensive to encode, transfer, and decode, so they maintain referential equality across Snapshots as long as the underlying file hasn't changed and at least one Snapshot that references them is still alive.

const snapshot = await api.updateSnapshot({ openProject: "/src/tsconfig.json" });
if (await api.getDefaultProjectForFile("/src/file.ts") === await snapshot.getDefaultProjectForFile("/src/file.ts")) {
    // Referential equality within snapshot; always true
}

const { program, checker } = snapshot.getProject("/src/tsconfig.json");
const file = await program.getSourceFile("/src/file.ts");
// Get the binder-created module symbol for the file:
const symbol = await checker.getSymbolAtLocation(file);

const newSnapshot = await api.updateSnapshot({ fileChanges: { invalidateAll: true } });
const newProject = await newSnapshot.getProject("/src/tsconfig.json");
project === newProject; // always false, different snapshot
const newFile = await newProject.program.getSourceFile("/src/file.ts");
file === newFile; // true if the file hasn't changed on disk
const newSymbol = await newProject.checker.getSymbolAtLocation(newFile);
symbol.id === newSymbol.id; // true if the file hasn't changed on disk
symbol === newSymbol;       // but always false, different snapshot!

In general, we don't expect API users to be in situations where they can accidentally mix objects from different snapshots. Getting a new snapshot is analogous to ts.createProgram() in the Strada API; for many use cases, you only ever call it once. In a real-time application like an editor plugin connected to the LSP server, you'll likely call api.updateSnapshot() at the beginning of each handler and never hold onto more than one at a time.

Snapshots have a dispose() method and a [Symbol.dispose]() so you can automatically clean up with using declarations. Disposing a Snapshot marks it as unusable, but the API always holds onto at least one Snapshot internally, which allows it to maintain the SourceFile cache across Snapshots updates that are spread out over time:

connection.onCodeAction(async (params: CodeActionParams): Promise<CodeAction[]> => {
    using snapshot = await api.updateSnapshot();
    const file = await snapshot.getSourceFile(params.textDocument.uri);
    // ...
    // snapshot gets marked for disposal at the end of the handler.
    // Next time updateSnapshot is called, the old snapshot will be disposed along with its
    // Projects, Types, and Symbols. Any SourceFiles that are unchanged in the new snapshot
    // will remain in the cache and have their ref transferred to the new snapshot. Any that
    // are only referenced by the old snapshot will be cleared.
});

Symbols have declarations and valueDeclaration information

API client methods that return Symbols now include information about their declarations. These objects are stubs of nodes containing enough information to resolve them to a full AST node, whether the file has already been fetched or not:

const symbol = await checker.getSymbolAtPosition("/src/file.ts", 123);
symbol.declarations;
// [NodeHandle {
//    path: "/src/file.ts",
//    kind: SyntaxKind.VariableDeclaration,
//    pos: 123,
//    end: 139,
// }]

// fetches the file if not already in the cache for the snapshot then returns the AST node
await symbol.declarations[0].resolve();

Program and Checker APIs namespaced

As seen in the earlier code examples, for organization and familiarity, project now includes program and checker properties that serve only to group related APIs.

const { program, checker } = snapshot.getProject("/src/tsconfig.json");
await program.getSourceFile("/src/file.ts");
await checker.getSymbolAtPosition("/src/file.ts", 123);

checker.resolveName() added

Along with symbol declarations, this was a method I needed in a small proof of concept, so here it is.

const symbol = await checker.resolveName("SomeType", SymbolFlags.Type, node);

Files are identified by file name or URI

To make it more convenient to work with the API in an LSP context, all API methods that took a filename now also accept a URI.

await snapshot.getSourceFile("/src/file.ts");
await snapshot.getSourceFile({ uri: "file:///src/file.ts" });

Async API supports virtual file system callbacks

Warning

This functionality is super useful for testing, but is a prime candidate for future removal. It doesn't work with the LSP-connected API and so we may replace it with a mechanism that works in both settings.

The async API client now supports the same client-side file system overrides as the sync client:

import { createVirtualFileSystem } from "@typescript/api/fs";
import { API } from "@typescript/api/async";

const fs = createVirtualFileSystem({
    "/src/tsconfig.json": JSON.stringify({
        compilerOptions: {
            module: "commonjs",
        },
    }),
    "/src/file.ts": `export const x: number = 123;`,
});

const api = new API({
    cwd: fileURLToPath(new URL("../../../", import.meta.url).toString()),
    tsserverPath: fileURLToPath(new URL(`../../../built/local/tsgo`, import.meta.url).toString()),
    fs,
});

Sync API moved from @typescript/api to @typescript/api/sync

A lot of the types are duplicated between the sync and async APIs with only sync/async differences which made it confusing as a user of the async API that your types were incompatible with import { Project } from "@typescript/api", which looks like it should be sync-agnostic. Now, only types that are identical between the APIs are exported from @typescript/api.

Copilot AI review requested due to automatic review settings February 18, 2026 00:22
@andrewbranch andrewbranch changed the title Api updates New IPC API client features Feb 18, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the programmatic API surface to be snapshot-based (multiple immutable snapshots with explicit lifetimes), adds snapshot diffing to support client-side caching, and updates the JS/TS client packages and tests accordingly.

Changes:

  • Replace “load project” style API with initialize + updateSnapshot, including snapshot handles and server-side ref-counted snapshot lifecycle.
  • Add snapshot change computation (per-project changed/deleted files) and introduce client-side SourceFileCache keyed by (path, parseOptionsKey, contentHash).
  • Rev the source-file binary encoding protocol (v2) to include a content hash and additional SourceFile extended data (path/id), and update consumers/tests.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/project/snapshot.go Adds InvalidateAll handling and reorders API request handling during snapshot cloning.
internal/project/projectcollectionbuilder.go Simplifies API request handling; updates all API-opened projects each request.
internal/project/filechange.go Adds InvalidateAll and a helper to merge API-provided file changes into session changes.
internal/project/api.go Introduces APIOpenProject + APIUpdateWithFileChanges to drive snapshot updates from API.
internal/lsp/server.go Adds cancellable context + panic recovery for API session connections.
internal/compiler/program.go Exposes FilesByPath() for snapshot diffing.
internal/api/session.go Implements multi-snapshot API session with ref-counting, per-snapshot registries, updateSnapshot + resolveName, and snapshot diffing.
internal/api/protocol_jsonrpc.go Ensures JSON-RPC responses encode null results explicitly.
internal/api/proto.go Updates protocol methods/params (initialize/updateSnapshot/release), document identifiers, node handles, and response shapes.
internal/api/encoder/encoder.go Bumps protocol to v2; adds content hash to header and extends SourceFile data (path/id).
internal/api/encoder/encoder_test.go Updates encoder tests for new EncodeSourceFile signature.
_packages/ast/src/nodes.ts Adds Path branded type and SourceFile.path.
_packages/api/src/* Introduces sync API implementation, reworks async API around snapshots, adds caching utilities, updates FS callback wiring, and removes old root export.
_packages/api/test/* Updates sync/async tests and benches for snapshot-based API + caching behavior.
_packages/api/package.json Changes exports to ./sync and keeps ./async.

@andrewbranch
Copy link
Member Author

My handling of SourceFileAffectingOptions needs to be updated after Jake's changes in main. I think it wasn't quite right before, anyway.


const (
ProtocolVersion uint8 = 1
ProtocolVersion uint8 = 3
Copy link
Member Author

Choose a reason for hiding this comment

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

Where did version 2 go??

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant