diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 444aed6179fb5..6e9eb3245fbd6 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/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..5ed5397cf55cd 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,15 +163,11 @@ export class Client { } } - async apiRequest(method: K, params: APIMethodInfo[K]["params"]): Promise { - if (!this.connected) { - await this.connect(); - } + private async sendRequestWithTiming(requestType: RequestType, params: unknown): Promise { if (!this.connection) { throw new Error("Connection not established"); } - const requestType = new RequestType(method); if (!this.timing) { return this.connection.sendRequest(requestType, params); } @@ -180,7 +181,7 @@ export class Client { const result = await this.connection.sendRequest(requestType, params); const roundTripMs = performance.now() - start; this.timing.record({ - method, + method: requestType.method, roundTripMs, bytesSent, bytesReceived: result === undefined || result === null @@ -190,6 +191,83 @@ export class Client { return result; } + 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"); + } + + 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; + } + + 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 = response.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; } { + 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]: () => { + this.nextBatch = undefined; + this.scheduleImmediateBatch(); + }, + }; + } + + async apiRequest(method: K, params: APIMethodInfo[K]["params"]): Promise { + if (!this.connected) { + await this.connect(); + } + if (!this.connection) { + throw new Error("Connection not established"); + } + + const resultPromise = new Promise((resolve, reject) => { + this.batchedRequests.push({ method, params, resolve, reject }); + this.scheduleImmediateBatch(); + }); + return resultPromise; + } + async apiRequestBinary(method: K, params: APIMethodInfo[K]["params"]): Promise { const response = await this.apiRequest(method, params); if (!response) return undefined; diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 35c08679d2aa4..6360445d3a8f3 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,295 @@ 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..6c29b4c3a4a13 100644 --- a/packages/typescript/src/api/proto.ts +++ b/packages/typescript/src/api/proto.ts @@ -25,6 +25,23 @@ 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?: undefined; + } | { + result: null; + 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..3657634dc6474 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,147 @@ 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.strictEqual(responses.length, 2); + const commandLine = responses[0]; + assert.strictEqual(commandLine.method, "parseCommandLine"); + assert.strictEqual(commandLine.error, undefined); + assert.equal(commandLine.result.options.strict, true); + + const config = responses[1]; + assert.strictEqual(config.method, "readConfigFile"); + assert.strictEqual(config.error, undefined); + assert.deepStrictEqual(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.strictEqual(commandLine.method, "parseCommandLine"); + assert.strictEqual(commandLine.error, undefined); + assert.strictEqual(commandLine.result.options.strict, true); + } + finally { + await api.close(); + } + }); +}); + +// @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(); + 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..82d72db103dda 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.strictEqual(responses.length, 2); + const commandLine = responses[0]; + assert.strictEqual(commandLine.method, "parseCommandLine"); + assert.strictEqual(commandLine.error, undefined); + assert.equal(commandLine.result.options.strict, true); + + const config = responses[1]; + assert.strictEqual(config.method, "readConfigFile"); + assert.strictEqual(config.error, undefined); + assert.deepStrictEqual(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.strictEqual(commandLine.method, "parseCommandLine"); + assert.strictEqual(commandLine.error, undefined); + assert.strictEqual(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":[]}`) +}