From 15f06b86f6558902f1d67928594d2f2f83f23c23 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Thu, 20 Aug 2026 15:48:53 -0700 Subject: [PATCH 1/4] Add arbitrary API request batching --- packages/typescript/src/api/async/api.ts | 15 +++ packages/typescript/src/api/async/client.ts | 107 ++++++++++++---- .../typescript/src/api/proto.generated.ts | 20 +++ packages/typescript/src/api/proto.ts | 11 ++ packages/typescript/src/api/sync/api.ts | 9 ++ packages/typescript/test/async/api.test.ts | 114 ++++++++++++++++++ packages/typescript/test/sync/api.test.ts | 50 ++++++++ tools/gen-proto/main.go | 9 ++ tsc/internal/api/proto.go | 22 ++++ tsc/internal/api/session.go | 27 +++++ tsc/internal/api/session_batch_test.go | 61 ++++++++++ 11 files changed, 424 insertions(+), 21 deletions(-) create mode 100644 tsc/internal/api/session_batch_test.go diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index ad787fb62495d..4a06af2fe9f11 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -49,6 +49,8 @@ import { toPath, } from "../path.ts"; import type { + APIRequest, + APIResponseTuple, CompilerOptions, Diagnostic, DocumentIdentifier, @@ -230,6 +232,19 @@ export class API { return api; } + async batchRequests(requests: Requests): Promise<{ responses: APIResponseTuple; }> { + const response = await this.client.apiRequest("batchRequests", { requests }); + // we're replacing the `unknown`s in the autogenerated types with much more specific per-request types here, which creates some variance issues for the + // `batchRequests` method within the batch request object itself, so we cast. + return response as { responses: APIResponseTuple; }; + } + + // @sync-skip-block-start + batchContext(): { [globalThis.Symbol.dispose](): void; } { + return this.client.batchContext(); + } + // @sync-skip-block-end + private async ensureInitialized(): Promise { if (!this.initialized) { const response = await this.client.apiRequest("initialize", null); diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 11eecc0b73b70..bde3262cb1b37 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -23,6 +23,9 @@ import { } from "../options.ts"; import type { APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, SourceFileResponseMethod, } from "../proto.ts"; import { @@ -47,6 +50,8 @@ export class Client { private options: ClientOptions; private connected = false; private timing: TimingCollector | undefined; + private batchedRequests: { method: APIRequest["method"]; params: APIRequest["params"]; resolve: (value: unknown) => void; reject: (reason?: any) => void; }[] = []; + private nextBatch: NodeJS.Immediate | "manual" | undefined; constructor(options: ClientOptions) { this.options = options; @@ -158,6 +163,83 @@ export class Client { } } + private async doBatch(): Promise { + this.nextBatch = undefined; + if (!this.batchedRequests.length) return; + const requests = this.batchedRequests; + this.batchedRequests = []; + try { + if (!this.connected) { + await this.connect(); + } + if (!this.connection) { + throw new Error("Connection not established"); + } + + const requestType = new RequestType("batchRequests"); + const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; + if (!this.timing) { + const response = await this.connection.sendRequest(requestType, params); + for (let i = 0; i < requests.length; i++) { + const { resolve, reject } = requests[i]; + const item = response.responses[i]; + if (item.error !== undefined) { + reject(new Error(item.error)); + } + else { + resolve(item.result); + } + } + return; + } + + // Round-trip latency is measured here; byte counts approximate the wire + // payload via the serialized JSON. Server-side processing time is not + // carried on the response; it is retrieved separately (via a + // getServerTiming request) and folded in by getTimingInfo(). + const bytesSent = params === undefined ? 0 : Buffer.byteLength(JSON.stringify(params), "utf-8"); + const start = performance.now(); + const result = await this.connection.sendRequest(requestType, params); + const roundTripMs = performance.now() - start; + this.timing.record({ + method: "batchRequests", + roundTripMs, + bytesSent, + bytesReceived: result === undefined || result === null + ? 0 + : Buffer.byteLength(JSON.stringify(result), "utf-8"), + }); + for (let i = 0; i < requests.length; i++) { + const { resolve, reject } = requests[i]; + const item = result.responses[i]; + if (item.error !== undefined) { + reject(new Error(item.error)); + } + else { + resolve(item.result); + } + } + } + catch (error) { + for (const { reject } of requests) reject(error); + } + } + + private scheduleImmediateBatch(): void { + if (this.nextBatch) return; + this.nextBatch = setImmediate(this.doBatch.bind(this)); + } + + batchContext(): { [Symbol.dispose](): void; } { + this.nextBatch = "manual"; + return { + [Symbol.dispose]: () => { + this.nextBatch = undefined; + this.scheduleImmediateBatch(); + }, + }; + } + async apiRequest(method: K, params: APIMethodInfo[K]["params"]): Promise { if (!this.connected) { await this.connect(); @@ -166,28 +248,11 @@ export class Client { throw new Error("Connection not established"); } - const requestType = new RequestType(method); - if (!this.timing) { - return this.connection.sendRequest(requestType, params); - } - - // Round-trip latency is measured here; byte counts approximate the wire - // payload via the serialized JSON. Server-side processing time is not - // carried on the response; it is retrieved separately (via a - // getServerTiming request) and folded in by getTimingInfo(). - const bytesSent = params === undefined ? 0 : Buffer.byteLength(JSON.stringify(params), "utf-8"); - const start = performance.now(); - const result = await this.connection.sendRequest(requestType, params); - const roundTripMs = performance.now() - start; - this.timing.record({ - method, - roundTripMs, - bytesSent, - bytesReceived: result === undefined || result === null - ? 0 - : Buffer.byteLength(JSON.stringify(result), "utf-8"), + const resultPromise = new Promise((resolve, reject) => { + this.batchedRequests.push({ method, params, resolve, reject }); + this.scheduleImmediateBatch(); }); - return result; + return resultPromise; } async apiRequestBinary(method: K, params: APIMethodInfo[K]["params"]): Promise { diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 35c08679d2aa4..e59146f1c9174 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -11,6 +11,7 @@ export type APIMethod = { params: TParams; result: TResult; }; export interface APIMethodInfo { release: APIMethod; + batchRequests: APIMethod; initialize: APIMethod; updateSnapshot: APIMethod; updateTemporarySnapshot: APIMethod; @@ -157,6 +158,14 @@ export interface ReleaseParams { snapshot: number; } +export interface BatchRequestsParams { + requests: readonly BatchRequest[] | null; +} + +export interface BatchRequestsResponse { + responses: BatchResponse[]; +} + /** InitializeResponse is returned by the initialize method. */ export interface InitializeResponse { /** UseCaseSensitiveFileNames indicates whether the host file system is case-sensitive. */ @@ -858,6 +867,17 @@ export interface ProfileResult { file: string; } +export interface BatchRequest { + method: "batchRequests" | "emit" | "emitToString" | "formatNodeForInsertion" | "getAliasSymbolOfType" | "getAliasTypeArgumentsOfType" | "getAliasedSymbol" | "getAnyType" | "getApparentPropertiesOfType" | "getApparentType" | "getBaseConstraintOfType" | "getBaseTypeOfLiteralType" | "getBaseTypeOfType" | "getBaseTypes" | "getBigIntType" | "getBindDiagnostics" | "getBooleanType" | "getCheckTypeOfType" | "getCompletionsAtPosition" | "getConfigFileNames" | "getConfigFileParsingDiagnostics" | "getConfigSourceFile" | "getConstantValue" | "getConstraintOfType" | "getConstraintOfTypeParameter" | "getContextualType" | "getDeclarationDiagnostics" | "getDeclarationEmit" | "getDeclaredTypeOfSymbol" | "getDefaultFromTypeParameter" | "getDefaultProjectForFile" | "getDocumentationComment" | "getESSymbolType" | "getExportSpecifierLocalTargetSymbol" | "getExportSymbolOfSymbol" | "getExportsOfModule" | "getExportsOfSymbol" | "getExtendsTypeOfType" | "getFalseTypeOfConditionalType" | "getFreshTypeOfType" | "getFullyQualifiedName" | "getGlobalDiagnostics" | "getImmediateAliasedSymbol" | "getImportAdderEdits" | "getIndexInfosOfType" | "getIndexTypeOfType" | "getJavaScriptEmit" | "getJsDocTags" | "getLocalTypeParametersOfType" | "getMemberInModuleExports" | "getMembersOfSymbol" | "getNeverType" | "getNonNullableType" | "getNonPrimitiveType" | "getNullType" | "getNumberType" | "getObjectTypeOfType" | "getOuterTypeParametersOfType" | "getParameterType" | "getParametersOfSignature" | "getParentOfSymbol" | "getProgramDiagnostics" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" | "getReferencedSymbolsForNode" | "getReferencesToSymbolInFile" | "getRegularTypeOfType" | "getResolvedSignature" | "getRestTypeOfSignature" | "getReturnTypeOfSignature" | "getSemanticDiagnostics" | "getShorthandAssignmentValueSymbol" | "getSignatureFromDeclaration" | "getSignatureUsages" | "getSignaturesOfType" | "getSourceFile" | "getSourceFileMetadata" | "getSourceFileNames" | "getStringType" | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" | "getSymbolOfSourceFile" | "getSymbolOfType" | "getSymbolsAtLocations" | "getSymbolsAtPositions" | "getSymbolsInScope" | "getSymbolsOfSourceFiles" | "getSyntacticDiagnostics" | "getTargetOfSignature" | "getTargetOfType" | "getThisParameterOfSignature" | "getTrueTypeOfConditionalType" | "getTypeArguments" | "getTypeAtLocation" | "getTypeAtLocations" | "getTypeAtPosition" | "getTypeFromTypeNode" | "getTypeOfSymbol" | "getTypeOfSymbolAtLocation" | "getTypeParameterAtPosition" | "getTypeParametersOfSignature" | "getTypeParametersOfType" | "getTypePredicateOfSignature" | "getTypesAtPositions" | "getTypesOfSymbols" | "getTypesOfType" | "getUndefinedType" | "getUnknownType" | "getVoidType" | "getWellKnownSignatures" | "getWellKnownSymbols" | "getWidenedType" | "initialize" | "isArrayLikeType" | "isArrayType" | "isContextSensitive" | "isTupleType" | "isTypeAssignableTo" | "parseCommandLine" | "parseConfigFile" | "parseJsonConfigFileContent" | "printNode" | "readConfigFile" | "release" | "resolveName" | "saveHeapProfile" | "signatureToSignatureDeclaration" | "startCPUProfile" | "stopCPUProfile" | "transpileDeclaration" | "transpileDeclarationFromFile" | "transpileModule" | "transpileModuleFromFile" | "typeToString" | "typeToTypeNode" | "updateSnapshot" | "updateTemporarySnapshot"; + params?: unknown; +} + +export interface BatchResponse { + method: "batchRequests" | "emit" | "emitToString" | "formatNodeForInsertion" | "getAliasSymbolOfType" | "getAliasTypeArgumentsOfType" | "getAliasedSymbol" | "getAnyType" | "getApparentPropertiesOfType" | "getApparentType" | "getBaseConstraintOfType" | "getBaseTypeOfLiteralType" | "getBaseTypeOfType" | "getBaseTypes" | "getBigIntType" | "getBindDiagnostics" | "getBooleanType" | "getCheckTypeOfType" | "getCompletionsAtPosition" | "getConfigFileNames" | "getConfigFileParsingDiagnostics" | "getConfigSourceFile" | "getConstantValue" | "getConstraintOfType" | "getConstraintOfTypeParameter" | "getContextualType" | "getDeclarationDiagnostics" | "getDeclarationEmit" | "getDeclaredTypeOfSymbol" | "getDefaultFromTypeParameter" | "getDefaultProjectForFile" | "getDocumentationComment" | "getESSymbolType" | "getExportSpecifierLocalTargetSymbol" | "getExportSymbolOfSymbol" | "getExportsOfModule" | "getExportsOfSymbol" | "getExtendsTypeOfType" | "getFalseTypeOfConditionalType" | "getFreshTypeOfType" | "getFullyQualifiedName" | "getGlobalDiagnostics" | "getImmediateAliasedSymbol" | "getImportAdderEdits" | "getIndexInfosOfType" | "getIndexTypeOfType" | "getJavaScriptEmit" | "getJsDocTags" | "getLocalTypeParametersOfType" | "getMemberInModuleExports" | "getMembersOfSymbol" | "getNeverType" | "getNonNullableType" | "getNonPrimitiveType" | "getNullType" | "getNumberType" | "getObjectTypeOfType" | "getOuterTypeParametersOfType" | "getParameterType" | "getParametersOfSignature" | "getParentOfSymbol" | "getProgramDiagnostics" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" | "getReferencedSymbolsForNode" | "getReferencesToSymbolInFile" | "getRegularTypeOfType" | "getResolvedSignature" | "getRestTypeOfSignature" | "getReturnTypeOfSignature" | "getSemanticDiagnostics" | "getShorthandAssignmentValueSymbol" | "getSignatureFromDeclaration" | "getSignatureUsages" | "getSignaturesOfType" | "getSourceFile" | "getSourceFileMetadata" | "getSourceFileNames" | "getStringType" | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" | "getSymbolOfSourceFile" | "getSymbolOfType" | "getSymbolsAtLocations" | "getSymbolsAtPositions" | "getSymbolsInScope" | "getSymbolsOfSourceFiles" | "getSyntacticDiagnostics" | "getTargetOfSignature" | "getTargetOfType" | "getThisParameterOfSignature" | "getTrueTypeOfConditionalType" | "getTypeArguments" | "getTypeAtLocation" | "getTypeAtLocations" | "getTypeAtPosition" | "getTypeFromTypeNode" | "getTypeOfSymbol" | "getTypeOfSymbolAtLocation" | "getTypeParameterAtPosition" | "getTypeParametersOfSignature" | "getTypeParametersOfType" | "getTypePredicateOfSignature" | "getTypesAtPositions" | "getTypesOfSymbols" | "getTypesOfType" | "getUndefinedType" | "getUnknownType" | "getVoidType" | "getWellKnownSignatures" | "getWellKnownSymbols" | "getWidenedType" | "initialize" | "isArrayLikeType" | "isArrayType" | "isContextSensitive" | "isTupleType" | "isTypeAssignableTo" | "parseCommandLine" | "parseConfigFile" | "parseJsonConfigFileContent" | "printNode" | "readConfigFile" | "release" | "resolveName" | "saveHeapProfile" | "signatureToSignatureDeclaration" | "startCPUProfile" | "stopCPUProfile" | "transpileDeclaration" | "transpileDeclarationFromFile" | "transpileModule" | "transpileModuleFromFile" | "typeToString" | "typeToTypeNode" | "updateSnapshot" | "updateTemporarySnapshot"; + result: unknown; + error?: string; +} + /** * APIFileChanges describes file changes to apply when updating a snapshot. * Either InvalidateAll is true (discard all caches) or Changed/Created/Deleted diff --git a/packages/typescript/src/api/proto.ts b/packages/typescript/src/api/proto.ts index 4f9b56b55b6ee..98590103e9f0b 100644 --- a/packages/typescript/src/api/proto.ts +++ b/packages/typescript/src/api/proto.ts @@ -25,6 +25,17 @@ export type TypePropertyMethod = Exclude, Intr export type TypesPropertyMethod = APIMethodsReturning; export type IntrinsicTypeMethod = "getAnyType" | "getBigIntType" | "getBooleanType" | "getESSymbolType" | "getNeverType" | "getNonPrimitiveType" | "getNullType" | "getNumberType" | "getStringType" | "getUndefinedType" | "getUnknownType" | "getVoidType"; +export type APIRequest = { [K in keyof APIMethodInfo]: { method: K; params: APIMethodInfo[K]["params"]; }; }[keyof APIMethodInfo]; +export type APIResponse = Request extends APIRequest ? { + method: Request["method"]; + result: APIMethodInfo[Request["method"]]["result"]; + error?: string; + } : + never; +export type APIResponseTuple = { + [Index in keyof Requests]: APIResponse; +}; + /** * A position within a document, combining a document identifier with an offset. */ diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 074471e86cfbb..8a64924c876a5 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -57,6 +57,8 @@ import { toPath, } from "../path.ts"; import type { + APIRequest, + APIResponseTuple, CompilerOptions, Diagnostic, DocumentIdentifier, @@ -238,6 +240,13 @@ export class API { return api; } + batchRequests(requests: Requests): { responses: APIResponseTuple; } { + const response = this.client.apiRequest("batchRequests", { requests }); + // we're replacing the `unknown`s in the autogenerated types with much more specific per-request types here, which creates some variance issues for the + // `batchRequests` method within the batch request object itself, so we cast. + return response as { responses: APIResponseTuple; }; + } + private ensureInitialized(): void { if (!this.initialized) { const response = this.client.apiRequest("initialize", null); diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index fe08206bc486b..6b3417af8d1d8 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -77,6 +77,7 @@ import { } from "@typescript/typescript/unstable/async"; // @sync: } from "@typescript/typescript/unstable/sync"; import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import type { FileSystem } from "@typescript/typescript/unstable/fs"; +import type { APIRequest } from "@typescript/typescript/unstable/proto"; import assert from "node:assert"; import { globSync } from "node:fs"; import { resolve } from "node:path"; @@ -380,6 +381,119 @@ describe("API", () => { }); }); +describe("API - batchRequests", () => { + test("returns results in request order", async () => { + const api = spawnAPI(); + try { + const { responses } = await api.batchRequests([ + { method: "parseCommandLine", params: { commandLine: ["--strict"] } }, + { method: "readConfigFile", params: { file: "/tsconfig.json" } }, + ]); + + assert.equal(responses.length, 2); + const commandLine = responses[0]; + assert.equal(commandLine.method, "parseCommandLine"); + assert.equal(commandLine.error, undefined); + assert.equal(commandLine.result.options.strict, true); + + const config = responses[1]; + assert.equal(config.method, "readConfigFile"); + assert.equal(config.error, undefined); + assert.deepEqual(config.result.config, {}); + } + finally { + await api.close(); + } + }); + + test("returns an item error without dropping a sibling result", async () => { + const api = spawnAPI(); + try { + const { responses } = await api.batchRequests([ + { method: "unknown", params: null } as unknown as APIRequest, + { method: "parseCommandLine", params: { commandLine: ["--strict"] } }, + ]); + + assert.equal(responses.length, 2); + assert.equal(responses[0].method, "unknown"); + assert.match(responses[0].error!, /unknown API method/); + assert.equal(responses[0].result, null); + + const commandLine = responses[1]; + assert.equal(commandLine.method, "parseCommandLine"); + assert.equal(commandLine.error, undefined); + assert.equal(commandLine.result.options.strict, true); + } + finally { + await api.close(); + } + }); +}); + +// @sync-skip-block-start +describe("API - batchContext", () => { + test("holds requests until disposal", async () => { + const api = spawnAPI(); + try { + await api.parseCommandLine([]); + + const requests = await (async () => { + using _ = api.batchContext(); + const requests = [ + api.parseCommandLine(["--strict"]), + api.readConfigFile("/tsconfig.json"), + ] as const; + let settled = false; + void Promise.all(requests).then(() => { + settled = true; + }); + + await new Promise(resolve => setImmediate(resolve)); + assert.equal(settled, false, "requests should remain pending inside the batch context"); + return requests; + })(); + + const [commandLine, config] = await Promise.all(requests); + assert.equal(commandLine.options.strict, true); + assert.deepEqual(config.config, {}); + } + finally { + await api.close(); + } + }); + + test("settles an item error without dropping a sibling result", async () => { + const src = `export const value: string = "";`; + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "/src/main.ts": src, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const symbol = await project.checker.getSymbolAtPosition("/src/main.ts", src.indexOf("value:")); + assert.ok(symbol); + const type = await project.checker.getTypeOfSymbol(symbol); + assert.ok(type); + + const requests = await (async () => { + using _ = api.batchContext(); + return [ + project.checker.getTypeArguments(type as unknown as TypeReference), + project.checker.getStringType(), + ] as const; + })(); + + await assert.rejects(requests[0], /panic:/); + assert.ok((await requests[1]).flags & TypeFlags.String); + } + finally { + await api.close(); + } + }); +}); +// @sync-skip-block-end + describe("Checker - getImmediateAliasedSymbol", () => { test("resolves one level of alias indirection", async () => { const api = spawnAPI({ diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 6974061a4a6fb..cccb25128d307 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -54,6 +54,7 @@ import { import { visitEachChild } from "@typescript/typescript/unstable/ast/visitor"; import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import type { FileSystem } from "@typescript/typescript/unstable/fs"; +import type { APIRequest } from "@typescript/typescript/unstable/proto"; import { API, type BigIntLiteralType, @@ -388,6 +389,55 @@ describe("API", () => { }); }); +describe("API - batchRequests", () => { + test("returns results in request order", () => { + const api = spawnAPI(); + try { + const { responses } = api.batchRequests([ + { method: "parseCommandLine", params: { commandLine: ["--strict"] } }, + { method: "readConfigFile", params: { file: "/tsconfig.json" } }, + ]); + + assert.equal(responses.length, 2); + const commandLine = responses[0]; + assert.equal(commandLine.method, "parseCommandLine"); + assert.equal(commandLine.error, undefined); + assert.equal(commandLine.result.options.strict, true); + + const config = responses[1]; + assert.equal(config.method, "readConfigFile"); + assert.equal(config.error, undefined); + assert.deepEqual(config.result.config, {}); + } + finally { + api.close(); + } + }); + + test("returns an item error without dropping a sibling result", () => { + const api = spawnAPI(); + try { + const { responses } = api.batchRequests([ + { method: "unknown", params: null } as unknown as APIRequest, + { method: "parseCommandLine", params: { commandLine: ["--strict"] } }, + ]); + + assert.equal(responses.length, 2); + assert.equal(responses[0].method, "unknown"); + assert.match(responses[0].error!, /unknown API method/); + assert.equal(responses[0].result, null); + + const commandLine = responses[1]; + assert.equal(commandLine.method, "parseCommandLine"); + assert.equal(commandLine.error, undefined); + assert.equal(commandLine.result.options.strict, true); + } + finally { + api.close(); + } + }); +}); + describe("Checker - getImmediateAliasedSymbol", () => { test("resolves one level of alias indirection", () => { const api = spawnAPI({ diff --git a/tools/gen-proto/main.go b/tools/gen-proto/main.go index 714c5a32652b8..2ae7eaebe1656 100644 --- a/tools/gen-proto/main.go +++ b/tools/gen-proto/main.go @@ -484,6 +484,9 @@ func basicType(t *types.Basic) string { func (r *typeRenderer) namedType(named *types.Named) string { obj := named.Obj() + if obj.Name() == "error" && obj.Pkg() == nil { + return "string" + } qualifiedName := obj.Pkg().Path() + "." + obj.Name() switch qualifiedName { case r.apiPackagePath + ".DocumentIdentifier": @@ -491,6 +494,12 @@ func (r *typeRenderer) namedType(named *types.Named) string { return "DocumentIdentifier" case "github.com/microsoft/TypeScript/tsc/internal/packagejson.JSONValue": return "unknown" + case "github.com/microsoft/TypeScript/tsc/internal/json.Value": + return "unknown" + case "github.com/go-json-experiment/json/jsontext.Value": // multiple ways to refer to this type depending on `go` version + return "unknown" + case "encoding/json/jsontext.Value": + return "unknown" case "github.com/microsoft/TypeScript/tsc/internal/core.Tristate": return "boolean" case "github.com/microsoft/TypeScript/tsc/internal/core.JsxEmit": diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 45793d96a61be..29029785254b6 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -61,6 +61,8 @@ func parseProjectHandle(handle ProjectID) tspath.Path { const ( MethodRelease Method = "release" + MethodBatchRequests Method = "batchRequests" + MethodInitialize Method = "initialize" MethodUpdateSnapshot Method = "updateSnapshot" MethodUpdateTemporarySnapshot Method = "updateTemporarySnapshot" @@ -401,6 +403,7 @@ type UpdateSnapshotResponse struct { } var unmarshalers = map[Method]func([]byte) (any, error){ + MethodBatchRequests: unmarshallerFor[BatchRequestsParams], MethodRelease: unmarshallerFor[ReleaseParams], MethodInitialize: noParams, MethodUpdateSnapshot: unmarshallerFor[UpdateSnapshotParams], @@ -610,6 +613,25 @@ type TranspileOutputResponse struct { SourceMapText string `json:"sourceMapText,omitempty"` } +type BatchRequestsParams struct { + Requests []BatchRequest `json:"requests"` +} + +type BatchRequest struct { + Method Method `json:"method"` + Params json.Value `json:"params,omitempty"` +} + +type BatchRequestsResponse struct { + Responses []BatchResponse `json:"responses" nonnil:"true"` +} + +type BatchResponse struct { + Method Method `json:"method"` + Result any `json:"result"` + Error string `json:"error,omitempty"` +} + // ReleaseParams are the parameters for the release method. type ReleaseParams struct { Snapshot SnapshotID `json:"snapshot"` diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index d5c668385b849..bf72d9136acb4 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "runtime/debug" "slices" "strconv" "strings" @@ -599,6 +600,8 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. } switch method { + case string(MethodBatchRequests): + return s.handleBatchRequests(ctx, parsed.(*BatchRequestsParams)) case string(MethodRelease): return s.handleRelease(ctx, parsed.(*ReleaseParams)) case string(MethodInitialize): @@ -880,6 +883,30 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. } } +func (s *Session) handleBatchRequests(ctx context.Context, params *BatchRequestsParams) (*BatchRequestsResponse, error) { + responses := make([]BatchResponse, len(params.Requests)) + for i, request := range params.Requests { + responses[i] = s.handleBatchRequest(ctx, request) + } + return &BatchRequestsResponse{Responses: responses}, nil +} + +func (s *Session) handleBatchRequest(ctx context.Context, request BatchRequest) (response BatchResponse) { + response.Method = request.Method + defer func() { + if recovered := recover(); recovered != nil { + response.Result = nil + response.Error = fmt.Sprintf("panic: %v\n%s", recovered, debug.Stack()) + } + }() + var err error + response.Result, err = s.HandleRequest(ctx, string(request.Method), request.Params) + if err != nil { + response.Error = err.Error() + } + return response +} + func (s *Session) handleStartCPUProfile(_ context.Context, params *ProfileParams) (any, error) { if params == nil || params.Dir == "" { return nil, fmt.Errorf("%w: dir is required", ErrClientError) diff --git a/tsc/internal/api/session_batch_test.go b/tsc/internal/api/session_batch_test.go new file mode 100644 index 0000000000000..1a57c51d52405 --- /dev/null +++ b/tsc/internal/api/session_batch_test.go @@ -0,0 +1,61 @@ +package api + +import ( + "context" + "strings" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/json" + "gotest.tools/v3/assert" +) + +func TestHandleBatchRequests(t *testing.T) { + t.Parallel() + + session := &Session{} + response, err := session.handleBatchRequests(context.Background(), &BatchRequestsParams{ + Requests: []BatchRequest{ + {Method: "ping", Params: json.Value{}}, + {Method: "unknown", Params: json.Value{}}, + }, + }) + + assert.NilError(t, err) + assert.Equal(t, len(response.Responses), 2) + assert.Equal(t, response.Responses[0].Method, Method("ping")) + assert.Equal(t, response.Responses[0].Result, "pong") + assert.Equal(t, response.Responses[0].Error, "") + assert.Equal(t, response.Responses[1].Method, Method("unknown")) + requestErr := response.Responses[1].Error + assert.Assert(t, strings.Contains(requestErr, "unknown API method")) + + encoded, err := json.Marshal(response) + assert.NilError(t, err) + assert.Assert(t, strings.Contains(string(encoded), `"error":"api: invalid request: unknown API method \"unknown\""`)) +} + +func TestHandleBatchRequestsRecoversPerRequestPanics(t *testing.T) { + t.Parallel() + + var session *Session + response, err := session.handleBatchRequests(context.Background(), &BatchRequestsParams{ + Requests: []BatchRequest{ + {Method: "ping", Params: json.Value{}}, + {Method: MethodGetAnyType, Params: json.Value(`{"snapshot":1,"project":"project"}`)}, + {Method: "ping", Params: json.Value{}}, + }, + }) + + assert.NilError(t, err) + assert.Equal(t, response.Responses[0].Result, "pong") + assert.Assert(t, strings.Contains(response.Responses[1].Error, "panic:")) + assert.Equal(t, response.Responses[2].Result, "pong") +} + +func TestBatchResponseEncodesEmptyResult(t *testing.T) { + t.Parallel() + + encoded, err := json.Marshal(BatchResponse{Method: MethodGetSignaturesOfType, Result: []any{}}) + assert.NilError(t, err) + assert.Equal(t, string(encoded), `{"method":"getSignaturesOfType","result":[]}`) +} From aae28274f5ea8c78d688af08e1a365634d28e59f Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Thu, 20 Aug 2026 17:26:49 -0700 Subject: [PATCH 2/4] format api on generate --- Herebyfile.mjs | 5 +- .../typescript/src/api/proto.generated.ts | 282 +++++++++++++++++- 2 files changed, 284 insertions(+), 3 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 444aed6179fb5..e3cc09bb23018 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -578,7 +578,10 @@ export const generateAST = task({ export const generateAPI = task({ name: "generate:api", description: "Generates API files from internal/api/proto.go and internal/api/session.go.", - run: () => $`go -C ./tools run ./gen-proto ../tsc/internal/api/proto.go ../packages/typescript/src/api/proto.generated.ts`, + run: async () => { + await $`go -C ./tools run ./gen-proto ../tsc/internal/api/proto.go ../packages/typescript/src/api/proto.generated.ts` + await $`npx dprint fmt packages/typescript/src/api/proto.generated.ts`; + }, }); // ── Vendored npm dependencies ─────────────────────────────────── diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index e59146f1c9174..6360445d3a8f3 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -868,12 +868,290 @@ export interface ProfileResult { } export interface BatchRequest { - method: "batchRequests" | "emit" | "emitToString" | "formatNodeForInsertion" | "getAliasSymbolOfType" | "getAliasTypeArgumentsOfType" | "getAliasedSymbol" | "getAnyType" | "getApparentPropertiesOfType" | "getApparentType" | "getBaseConstraintOfType" | "getBaseTypeOfLiteralType" | "getBaseTypeOfType" | "getBaseTypes" | "getBigIntType" | "getBindDiagnostics" | "getBooleanType" | "getCheckTypeOfType" | "getCompletionsAtPosition" | "getConfigFileNames" | "getConfigFileParsingDiagnostics" | "getConfigSourceFile" | "getConstantValue" | "getConstraintOfType" | "getConstraintOfTypeParameter" | "getContextualType" | "getDeclarationDiagnostics" | "getDeclarationEmit" | "getDeclaredTypeOfSymbol" | "getDefaultFromTypeParameter" | "getDefaultProjectForFile" | "getDocumentationComment" | "getESSymbolType" | "getExportSpecifierLocalTargetSymbol" | "getExportSymbolOfSymbol" | "getExportsOfModule" | "getExportsOfSymbol" | "getExtendsTypeOfType" | "getFalseTypeOfConditionalType" | "getFreshTypeOfType" | "getFullyQualifiedName" | "getGlobalDiagnostics" | "getImmediateAliasedSymbol" | "getImportAdderEdits" | "getIndexInfosOfType" | "getIndexTypeOfType" | "getJavaScriptEmit" | "getJsDocTags" | "getLocalTypeParametersOfType" | "getMemberInModuleExports" | "getMembersOfSymbol" | "getNeverType" | "getNonNullableType" | "getNonPrimitiveType" | "getNullType" | "getNumberType" | "getObjectTypeOfType" | "getOuterTypeParametersOfType" | "getParameterType" | "getParametersOfSignature" | "getParentOfSymbol" | "getProgramDiagnostics" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" | "getReferencedSymbolsForNode" | "getReferencesToSymbolInFile" | "getRegularTypeOfType" | "getResolvedSignature" | "getRestTypeOfSignature" | "getReturnTypeOfSignature" | "getSemanticDiagnostics" | "getShorthandAssignmentValueSymbol" | "getSignatureFromDeclaration" | "getSignatureUsages" | "getSignaturesOfType" | "getSourceFile" | "getSourceFileMetadata" | "getSourceFileNames" | "getStringType" | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" | "getSymbolOfSourceFile" | "getSymbolOfType" | "getSymbolsAtLocations" | "getSymbolsAtPositions" | "getSymbolsInScope" | "getSymbolsOfSourceFiles" | "getSyntacticDiagnostics" | "getTargetOfSignature" | "getTargetOfType" | "getThisParameterOfSignature" | "getTrueTypeOfConditionalType" | "getTypeArguments" | "getTypeAtLocation" | "getTypeAtLocations" | "getTypeAtPosition" | "getTypeFromTypeNode" | "getTypeOfSymbol" | "getTypeOfSymbolAtLocation" | "getTypeParameterAtPosition" | "getTypeParametersOfSignature" | "getTypeParametersOfType" | "getTypePredicateOfSignature" | "getTypesAtPositions" | "getTypesOfSymbols" | "getTypesOfType" | "getUndefinedType" | "getUnknownType" | "getVoidType" | "getWellKnownSignatures" | "getWellKnownSymbols" | "getWidenedType" | "initialize" | "isArrayLikeType" | "isArrayType" | "isContextSensitive" | "isTupleType" | "isTypeAssignableTo" | "parseCommandLine" | "parseConfigFile" | "parseJsonConfigFileContent" | "printNode" | "readConfigFile" | "release" | "resolveName" | "saveHeapProfile" | "signatureToSignatureDeclaration" | "startCPUProfile" | "stopCPUProfile" | "transpileDeclaration" | "transpileDeclarationFromFile" | "transpileModule" | "transpileModuleFromFile" | "typeToString" | "typeToTypeNode" | "updateSnapshot" | "updateTemporarySnapshot"; + method: + | "batchRequests" + | "emit" + | "emitToString" + | "formatNodeForInsertion" + | "getAliasSymbolOfType" + | "getAliasTypeArgumentsOfType" + | "getAliasedSymbol" + | "getAnyType" + | "getApparentPropertiesOfType" + | "getApparentType" + | "getBaseConstraintOfType" + | "getBaseTypeOfLiteralType" + | "getBaseTypeOfType" + | "getBaseTypes" + | "getBigIntType" + | "getBindDiagnostics" + | "getBooleanType" + | "getCheckTypeOfType" + | "getCompletionsAtPosition" + | "getConfigFileNames" + | "getConfigFileParsingDiagnostics" + | "getConfigSourceFile" + | "getConstantValue" + | "getConstraintOfType" + | "getConstraintOfTypeParameter" + | "getContextualType" + | "getDeclarationDiagnostics" + | "getDeclarationEmit" + | "getDeclaredTypeOfSymbol" + | "getDefaultFromTypeParameter" + | "getDefaultProjectForFile" + | "getDocumentationComment" + | "getESSymbolType" + | "getExportSpecifierLocalTargetSymbol" + | "getExportSymbolOfSymbol" + | "getExportsOfModule" + | "getExportsOfSymbol" + | "getExtendsTypeOfType" + | "getFalseTypeOfConditionalType" + | "getFreshTypeOfType" + | "getFullyQualifiedName" + | "getGlobalDiagnostics" + | "getImmediateAliasedSymbol" + | "getImportAdderEdits" + | "getIndexInfosOfType" + | "getIndexTypeOfType" + | "getJavaScriptEmit" + | "getJsDocTags" + | "getLocalTypeParametersOfType" + | "getMemberInModuleExports" + | "getMembersOfSymbol" + | "getNeverType" + | "getNonNullableType" + | "getNonPrimitiveType" + | "getNullType" + | "getNumberType" + | "getObjectTypeOfType" + | "getOuterTypeParametersOfType" + | "getParameterType" + | "getParametersOfSignature" + | "getParentOfSymbol" + | "getProgramDiagnostics" + | "getPropertiesOfType" + | "getPropertyOfType" + | "getReducedType" + | "getReferencedSymbolsForNode" + | "getReferencesToSymbolInFile" + | "getRegularTypeOfType" + | "getResolvedSignature" + | "getRestTypeOfSignature" + | "getReturnTypeOfSignature" + | "getSemanticDiagnostics" + | "getShorthandAssignmentValueSymbol" + | "getSignatureFromDeclaration" + | "getSignatureUsages" + | "getSignaturesOfType" + | "getSourceFile" + | "getSourceFileMetadata" + | "getSourceFileNames" + | "getStringType" + | "getSuggestionDiagnostics" + | "getSymbolAtLocation" + | "getSymbolAtPosition" + | "getSymbolOfSourceFile" + | "getSymbolOfType" + | "getSymbolsAtLocations" + | "getSymbolsAtPositions" + | "getSymbolsInScope" + | "getSymbolsOfSourceFiles" + | "getSyntacticDiagnostics" + | "getTargetOfSignature" + | "getTargetOfType" + | "getThisParameterOfSignature" + | "getTrueTypeOfConditionalType" + | "getTypeArguments" + | "getTypeAtLocation" + | "getTypeAtLocations" + | "getTypeAtPosition" + | "getTypeFromTypeNode" + | "getTypeOfSymbol" + | "getTypeOfSymbolAtLocation" + | "getTypeParameterAtPosition" + | "getTypeParametersOfSignature" + | "getTypeParametersOfType" + | "getTypePredicateOfSignature" + | "getTypesAtPositions" + | "getTypesOfSymbols" + | "getTypesOfType" + | "getUndefinedType" + | "getUnknownType" + | "getVoidType" + | "getWellKnownSignatures" + | "getWellKnownSymbols" + | "getWidenedType" + | "initialize" + | "isArrayLikeType" + | "isArrayType" + | "isContextSensitive" + | "isTupleType" + | "isTypeAssignableTo" + | "parseCommandLine" + | "parseConfigFile" + | "parseJsonConfigFileContent" + | "printNode" + | "readConfigFile" + | "release" + | "resolveName" + | "saveHeapProfile" + | "signatureToSignatureDeclaration" + | "startCPUProfile" + | "stopCPUProfile" + | "transpileDeclaration" + | "transpileDeclarationFromFile" + | "transpileModule" + | "transpileModuleFromFile" + | "typeToString" + | "typeToTypeNode" + | "updateSnapshot" + | "updateTemporarySnapshot"; params?: unknown; } export interface BatchResponse { - method: "batchRequests" | "emit" | "emitToString" | "formatNodeForInsertion" | "getAliasSymbolOfType" | "getAliasTypeArgumentsOfType" | "getAliasedSymbol" | "getAnyType" | "getApparentPropertiesOfType" | "getApparentType" | "getBaseConstraintOfType" | "getBaseTypeOfLiteralType" | "getBaseTypeOfType" | "getBaseTypes" | "getBigIntType" | "getBindDiagnostics" | "getBooleanType" | "getCheckTypeOfType" | "getCompletionsAtPosition" | "getConfigFileNames" | "getConfigFileParsingDiagnostics" | "getConfigSourceFile" | "getConstantValue" | "getConstraintOfType" | "getConstraintOfTypeParameter" | "getContextualType" | "getDeclarationDiagnostics" | "getDeclarationEmit" | "getDeclaredTypeOfSymbol" | "getDefaultFromTypeParameter" | "getDefaultProjectForFile" | "getDocumentationComment" | "getESSymbolType" | "getExportSpecifierLocalTargetSymbol" | "getExportSymbolOfSymbol" | "getExportsOfModule" | "getExportsOfSymbol" | "getExtendsTypeOfType" | "getFalseTypeOfConditionalType" | "getFreshTypeOfType" | "getFullyQualifiedName" | "getGlobalDiagnostics" | "getImmediateAliasedSymbol" | "getImportAdderEdits" | "getIndexInfosOfType" | "getIndexTypeOfType" | "getJavaScriptEmit" | "getJsDocTags" | "getLocalTypeParametersOfType" | "getMemberInModuleExports" | "getMembersOfSymbol" | "getNeverType" | "getNonNullableType" | "getNonPrimitiveType" | "getNullType" | "getNumberType" | "getObjectTypeOfType" | "getOuterTypeParametersOfType" | "getParameterType" | "getParametersOfSignature" | "getParentOfSymbol" | "getProgramDiagnostics" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" | "getReferencedSymbolsForNode" | "getReferencesToSymbolInFile" | "getRegularTypeOfType" | "getResolvedSignature" | "getRestTypeOfSignature" | "getReturnTypeOfSignature" | "getSemanticDiagnostics" | "getShorthandAssignmentValueSymbol" | "getSignatureFromDeclaration" | "getSignatureUsages" | "getSignaturesOfType" | "getSourceFile" | "getSourceFileMetadata" | "getSourceFileNames" | "getStringType" | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" | "getSymbolOfSourceFile" | "getSymbolOfType" | "getSymbolsAtLocations" | "getSymbolsAtPositions" | "getSymbolsInScope" | "getSymbolsOfSourceFiles" | "getSyntacticDiagnostics" | "getTargetOfSignature" | "getTargetOfType" | "getThisParameterOfSignature" | "getTrueTypeOfConditionalType" | "getTypeArguments" | "getTypeAtLocation" | "getTypeAtLocations" | "getTypeAtPosition" | "getTypeFromTypeNode" | "getTypeOfSymbol" | "getTypeOfSymbolAtLocation" | "getTypeParameterAtPosition" | "getTypeParametersOfSignature" | "getTypeParametersOfType" | "getTypePredicateOfSignature" | "getTypesAtPositions" | "getTypesOfSymbols" | "getTypesOfType" | "getUndefinedType" | "getUnknownType" | "getVoidType" | "getWellKnownSignatures" | "getWellKnownSymbols" | "getWidenedType" | "initialize" | "isArrayLikeType" | "isArrayType" | "isContextSensitive" | "isTupleType" | "isTypeAssignableTo" | "parseCommandLine" | "parseConfigFile" | "parseJsonConfigFileContent" | "printNode" | "readConfigFile" | "release" | "resolveName" | "saveHeapProfile" | "signatureToSignatureDeclaration" | "startCPUProfile" | "stopCPUProfile" | "transpileDeclaration" | "transpileDeclarationFromFile" | "transpileModule" | "transpileModuleFromFile" | "typeToString" | "typeToTypeNode" | "updateSnapshot" | "updateTemporarySnapshot"; + method: + | "batchRequests" + | "emit" + | "emitToString" + | "formatNodeForInsertion" + | "getAliasSymbolOfType" + | "getAliasTypeArgumentsOfType" + | "getAliasedSymbol" + | "getAnyType" + | "getApparentPropertiesOfType" + | "getApparentType" + | "getBaseConstraintOfType" + | "getBaseTypeOfLiteralType" + | "getBaseTypeOfType" + | "getBaseTypes" + | "getBigIntType" + | "getBindDiagnostics" + | "getBooleanType" + | "getCheckTypeOfType" + | "getCompletionsAtPosition" + | "getConfigFileNames" + | "getConfigFileParsingDiagnostics" + | "getConfigSourceFile" + | "getConstantValue" + | "getConstraintOfType" + | "getConstraintOfTypeParameter" + | "getContextualType" + | "getDeclarationDiagnostics" + | "getDeclarationEmit" + | "getDeclaredTypeOfSymbol" + | "getDefaultFromTypeParameter" + | "getDefaultProjectForFile" + | "getDocumentationComment" + | "getESSymbolType" + | "getExportSpecifierLocalTargetSymbol" + | "getExportSymbolOfSymbol" + | "getExportsOfModule" + | "getExportsOfSymbol" + | "getExtendsTypeOfType" + | "getFalseTypeOfConditionalType" + | "getFreshTypeOfType" + | "getFullyQualifiedName" + | "getGlobalDiagnostics" + | "getImmediateAliasedSymbol" + | "getImportAdderEdits" + | "getIndexInfosOfType" + | "getIndexTypeOfType" + | "getJavaScriptEmit" + | "getJsDocTags" + | "getLocalTypeParametersOfType" + | "getMemberInModuleExports" + | "getMembersOfSymbol" + | "getNeverType" + | "getNonNullableType" + | "getNonPrimitiveType" + | "getNullType" + | "getNumberType" + | "getObjectTypeOfType" + | "getOuterTypeParametersOfType" + | "getParameterType" + | "getParametersOfSignature" + | "getParentOfSymbol" + | "getProgramDiagnostics" + | "getPropertiesOfType" + | "getPropertyOfType" + | "getReducedType" + | "getReferencedSymbolsForNode" + | "getReferencesToSymbolInFile" + | "getRegularTypeOfType" + | "getResolvedSignature" + | "getRestTypeOfSignature" + | "getReturnTypeOfSignature" + | "getSemanticDiagnostics" + | "getShorthandAssignmentValueSymbol" + | "getSignatureFromDeclaration" + | "getSignatureUsages" + | "getSignaturesOfType" + | "getSourceFile" + | "getSourceFileMetadata" + | "getSourceFileNames" + | "getStringType" + | "getSuggestionDiagnostics" + | "getSymbolAtLocation" + | "getSymbolAtPosition" + | "getSymbolOfSourceFile" + | "getSymbolOfType" + | "getSymbolsAtLocations" + | "getSymbolsAtPositions" + | "getSymbolsInScope" + | "getSymbolsOfSourceFiles" + | "getSyntacticDiagnostics" + | "getTargetOfSignature" + | "getTargetOfType" + | "getThisParameterOfSignature" + | "getTrueTypeOfConditionalType" + | "getTypeArguments" + | "getTypeAtLocation" + | "getTypeAtLocations" + | "getTypeAtPosition" + | "getTypeFromTypeNode" + | "getTypeOfSymbol" + | "getTypeOfSymbolAtLocation" + | "getTypeParameterAtPosition" + | "getTypeParametersOfSignature" + | "getTypeParametersOfType" + | "getTypePredicateOfSignature" + | "getTypesAtPositions" + | "getTypesOfSymbols" + | "getTypesOfType" + | "getUndefinedType" + | "getUnknownType" + | "getVoidType" + | "getWellKnownSignatures" + | "getWellKnownSymbols" + | "getWidenedType" + | "initialize" + | "isArrayLikeType" + | "isArrayType" + | "isContextSensitive" + | "isTupleType" + | "isTypeAssignableTo" + | "parseCommandLine" + | "parseConfigFile" + | "parseJsonConfigFileContent" + | "printNode" + | "readConfigFile" + | "release" + | "resolveName" + | "saveHeapProfile" + | "signatureToSignatureDeclaration" + | "startCPUProfile" + | "stopCPUProfile" + | "transpileDeclaration" + | "transpileDeclarationFromFile" + | "transpileModule" + | "transpileModuleFromFile" + | "typeToString" + | "typeToTypeNode" + | "updateSnapshot" + | "updateTemporarySnapshot"; result: unknown; error?: string; } From 48ca2f151e4cb88413a73cb958b1bce368e3f991 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Thu, 20 Aug 2026 17:50:44 -0700 Subject: [PATCH 3/4] Ok enough suggestions --- Herebyfile.mjs | 2 +- packages/typescript/src/api/async/client.ts | 7 ++++ packages/typescript/src/api/proto.ts | 16 ++++--- packages/typescript/test/async/api.test.ts | 46 +++++++++++++++++---- packages/typescript/test/sync/api.test.ts | 18 ++++---- 5 files changed, 65 insertions(+), 24 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index e3cc09bb23018..6e9eb3245fbd6 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -579,7 +579,7 @@ export const generateAPI = task({ name: "generate:api", description: "Generates API files from internal/api/proto.go and internal/api/session.go.", run: async () => { - await $`go -C ./tools run ./gen-proto ../tsc/internal/api/proto.go ../packages/typescript/src/api/proto.generated.ts` + await $`go -C ./tools run ./gen-proto ../tsc/internal/api/proto.go ../packages/typescript/src/api/proto.generated.ts`; await $`npx dprint fmt packages/typescript/src/api/proto.generated.ts`; }, }); diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index bde3262cb1b37..39331d32299fb 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -231,6 +231,13 @@ export class Client { } batchContext(): { [Symbol.dispose](): void; } { + if (this.nextBatch === "manual") { + throw new Error("Already in a manual batch context"); + } + if (this.nextBatch) { + clearImmediate(this.nextBatch); + this.doBatch(); // empty the queue before entering a manual batch context + } this.nextBatch = "manual"; return { [Symbol.dispose]: () => { diff --git a/packages/typescript/src/api/proto.ts b/packages/typescript/src/api/proto.ts index 98590103e9f0b..6c29b4c3a4a13 100644 --- a/packages/typescript/src/api/proto.ts +++ b/packages/typescript/src/api/proto.ts @@ -26,11 +26,17 @@ export type TypesPropertyMethod = APIMethodsReturning; export type IntrinsicTypeMethod = "getAnyType" | "getBigIntType" | "getBooleanType" | "getESSymbolType" | "getNeverType" | "getNonPrimitiveType" | "getNullType" | "getNumberType" | "getStringType" | "getUndefinedType" | "getUnknownType" | "getVoidType"; export type APIRequest = { [K in keyof APIMethodInfo]: { method: K; params: APIMethodInfo[K]["params"]; }; }[keyof APIMethodInfo]; -export type APIResponse = Request extends APIRequest ? { - method: Request["method"]; - result: APIMethodInfo[Request["method"]]["result"]; - error?: string; - } : +export type APIResponse = Request extends APIRequest ? + & { + method: Request["method"]; + } + & ({ + result: APIMethodInfo[Request["method"]]["result"]; + error?: undefined; + } | { + result: null; + error: string; + }) : never; export type APIResponseTuple = { [Index in keyof Requests]: APIResponse; diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 6b3417af8d1d8..3657634dc6474 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -390,16 +390,16 @@ describe("API - batchRequests", () => { { method: "readConfigFile", params: { file: "/tsconfig.json" } }, ]); - assert.equal(responses.length, 2); + assert.strictEqual(responses.length, 2); const commandLine = responses[0]; - assert.equal(commandLine.method, "parseCommandLine"); - assert.equal(commandLine.error, undefined); + assert.strictEqual(commandLine.method, "parseCommandLine"); + assert.strictEqual(commandLine.error, undefined); assert.equal(commandLine.result.options.strict, true); const config = responses[1]; - assert.equal(config.method, "readConfigFile"); - assert.equal(config.error, undefined); - assert.deepEqual(config.result.config, {}); + assert.strictEqual(config.method, "readConfigFile"); + assert.strictEqual(config.error, undefined); + assert.deepStrictEqual(config.result.config, {}); } finally { await api.close(); @@ -420,9 +420,9 @@ describe("API - batchRequests", () => { assert.equal(responses[0].result, null); const commandLine = responses[1]; - assert.equal(commandLine.method, "parseCommandLine"); - assert.equal(commandLine.error, undefined); - assert.equal(commandLine.result.options.strict, true); + assert.strictEqual(commandLine.method, "parseCommandLine"); + assert.strictEqual(commandLine.error, undefined); + assert.strictEqual(commandLine.result.options.strict, true); } finally { await api.close(); @@ -431,6 +431,34 @@ describe("API - batchRequests", () => { }); // @sync-skip-block-start +describe("API - automatic batching", () => { + test("batches multiple concurrent requests into one automatically", async () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: createVirtualFileSystem(defaultFiles), + collectTiming: true, + }); + try { + await api.parseCommandLine([]); // initialize API + await api.resetTimingInfo(); + let { totals: { requestCount } } = await api.getTimingInfo(); + assert.equal(requestCount, 0); + const [parseCommandLine, readConfigFile] = await Promise.all([ + api.parseCommandLine(["--strict"]), + api.readConfigFile("/tsconfig.json"), + ]); + ({ totals: { requestCount } } = await api.getTimingInfo()); + assert.equal(requestCount, 1); + + assert.equal(parseCommandLine.options.strict, true); + assert.deepStrictEqual(readConfigFile.config, {}); + } + finally { + api.close(); + } + }); +}); + describe("API - batchContext", () => { test("holds requests until disposal", async () => { const api = spawnAPI(); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index cccb25128d307..82d72db103dda 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -398,16 +398,16 @@ describe("API - batchRequests", () => { { method: "readConfigFile", params: { file: "/tsconfig.json" } }, ]); - assert.equal(responses.length, 2); + assert.strictEqual(responses.length, 2); const commandLine = responses[0]; - assert.equal(commandLine.method, "parseCommandLine"); - assert.equal(commandLine.error, undefined); + assert.strictEqual(commandLine.method, "parseCommandLine"); + assert.strictEqual(commandLine.error, undefined); assert.equal(commandLine.result.options.strict, true); const config = responses[1]; - assert.equal(config.method, "readConfigFile"); - assert.equal(config.error, undefined); - assert.deepEqual(config.result.config, {}); + assert.strictEqual(config.method, "readConfigFile"); + assert.strictEqual(config.error, undefined); + assert.deepStrictEqual(config.result.config, {}); } finally { api.close(); @@ -428,9 +428,9 @@ describe("API - batchRequests", () => { assert.equal(responses[0].result, null); const commandLine = responses[1]; - assert.equal(commandLine.method, "parseCommandLine"); - assert.equal(commandLine.error, undefined); - assert.equal(commandLine.result.options.strict, true); + assert.strictEqual(commandLine.method, "parseCommandLine"); + assert.strictEqual(commandLine.error, undefined); + assert.strictEqual(commandLine.result.options.strict, true); } finally { api.close(); From a0d4aa0e2873485b762cee26019f2d4cdc341335 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Thu, 20 Aug 2026 18:00:59 -0700 Subject: [PATCH 4/4] Only auto-send batch request if more than one request is actually queued --- packages/typescript/src/api/async/client.ts | 68 +++++++++++---------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 39331d32299fb..5ed5397cf55cd 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -163,6 +163,34 @@ export class Client { } } + private async sendRequestWithTiming(requestType: RequestType, params: unknown): Promise { + if (!this.connection) { + throw new Error("Connection not established"); + } + + if (!this.timing) { + return this.connection.sendRequest(requestType, params); + } + + // Round-trip latency is measured here; byte counts approximate the wire + // payload via the serialized JSON. Server-side processing time is not + // carried on the response; it is retrieved separately (via a + // getServerTiming request) and folded in by getTimingInfo(). + const bytesSent = params === undefined ? 0 : Buffer.byteLength(JSON.stringify(params), "utf-8"); + const start = performance.now(); + const result = await this.connection.sendRequest(requestType, params); + const roundTripMs = performance.now() - start; + this.timing.record({ + method: requestType.method, + roundTripMs, + bytesSent, + bytesReceived: result === undefined || result === null + ? 0 + : Buffer.byteLength(JSON.stringify(result), "utf-8"), + }); + return result; + } + private async doBatch(): Promise { this.nextBatch = undefined; if (!this.batchedRequests.length) return; @@ -176,42 +204,20 @@ export class Client { throw new Error("Connection not established"); } - const requestType = new RequestType("batchRequests"); - const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; - if (!this.timing) { - const response = await this.connection.sendRequest(requestType, params); - for (let i = 0; i < requests.length; i++) { - const { resolve, reject } = requests[i]; - const item = response.responses[i]; - if (item.error !== undefined) { - reject(new Error(item.error)); - } - else { - resolve(item.result); - } - } + if (requests.length === 1) { + // send single queued requests directly instead of as a batched request + const requestType = new RequestType(requests[0].method); + const response = await this.sendRequestWithTiming(requestType, requests[0].params); + requests[0].resolve(response); return; } - // Round-trip latency is measured here; byte counts approximate the wire - // payload via the serialized JSON. Server-side processing time is not - // carried on the response; it is retrieved separately (via a - // getServerTiming request) and folded in by getTimingInfo(). - const bytesSent = params === undefined ? 0 : Buffer.byteLength(JSON.stringify(params), "utf-8"); - const start = performance.now(); - const result = await this.connection.sendRequest(requestType, params); - const roundTripMs = performance.now() - start; - this.timing.record({ - method: "batchRequests", - roundTripMs, - bytesSent, - bytesReceived: result === undefined || result === null - ? 0 - : Buffer.byteLength(JSON.stringify(result), "utf-8"), - }); + const requestType = new RequestType("batchRequests"); + const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; + const response = await this.sendRequestWithTiming(requestType, params); for (let i = 0; i < requests.length; i++) { const { resolve, reject } = requests[i]; - const item = result.responses[i]; + const item = response.responses[i]; if (item.error !== undefined) { reject(new Error(item.error)); }