TypeScript/src/server/typingInstallerAdapter.ts

256 lines
10 KiB
TypeScript

import {
ApplyCodeActionCommandResult,
assertType,
createQueue,
Debug,
JsTyping,
MapLike,
server,
SortedReadonlyArray,
TypeAcquisition,
} from "./_namespaces/ts.js";
import {
ActionInvalidate,
ActionPackageInstalled,
ActionSet,
ActionWatchTypingLocations,
BeginInstallTypes,
createInstallTypingsRequest,
DiscoverTypings,
EndInstallTypes,
Event,
EventBeginInstallTypes,
EventEndInstallTypes,
EventInitializationFailed,
EventTypesRegistry,
InitializationFailedResponse,
InstallPackageOptionsWithProject,
InstallPackageRequest,
InvalidateCachedTypings,
ITypingsInstaller,
Logger,
LogLevel,
PackageInstalledResponse,
Project,
ProjectService,
protocol,
ServerHost,
SetTypings,
stringifyIndented,
TypesRegistryResponse,
TypingInstallerRequestUnion,
} from "./_namespaces/ts.server.js";
/** @internal */
export interface TypingsInstallerWorkerProcess {
send<T extends TypingInstallerRequestUnion>(rq: T): void;
}
interface PackageInstallPromise {
resolve(value: ApplyCodeActionCommandResult): void;
reject(reason: unknown): void;
}
/** @internal */
export abstract class TypingsInstallerAdapter implements ITypingsInstaller {
protected installer!: TypingsInstallerWorkerProcess;
private projectService!: ProjectService;
protected activeRequestCount = 0;
private requestQueue = createQueue<DiscoverTypings>();
private requestMap = new Map<string, DiscoverTypings>(); // Maps project name to newest requestQueue entry for that project
/** We will lazily request the types registry on the first call to `isKnownTypesPackageName` and store it in `typesRegistryCache`. */
private requestedRegistry = false;
private typesRegistryCache: Map<string, MapLike<string>> | undefined;
// This number is essentially arbitrary. Processing more than one typings request
// at a time makes sense, but having too many in the pipe results in a hang
// (see https://github.com/nodejs/node/issues/7657).
// It would be preferable to base our limit on the amount of space left in the
// buffer, but we have yet to find a way to retrieve that value.
private static readonly requestDelayMillis = 100;
private packageInstalledPromise: Map<number, PackageInstallPromise> | undefined;
private packageInstallId = 0;
constructor(
protected readonly telemetryEnabled: boolean,
protected readonly logger: Logger,
protected readonly host: ServerHost,
readonly globalTypingsCacheLocation: string,
protected event: Event,
private readonly maxActiveRequestCount: number,
) {
}
isKnownTypesPackageName(name: string): boolean {
// We want to avoid looking this up in the registry as that is expensive. So first check that it's actually an NPM package.
const validationResult = JsTyping.validatePackageName(name);
if (validationResult !== JsTyping.NameValidationResult.Ok) {
return false;
}
if (!this.requestedRegistry) {
this.requestedRegistry = true;
this.installer.send({ kind: "typesRegistry" });
}
return !!this.typesRegistryCache?.has(name);
}
installPackage(options: InstallPackageOptionsWithProject): Promise<ApplyCodeActionCommandResult> {
this.packageInstallId++;
const request: InstallPackageRequest = { kind: "installPackage", ...options, id: this.packageInstallId };
const promise = new Promise<ApplyCodeActionCommandResult>((resolve, reject) => {
(this.packageInstalledPromise ??= new Map()).set(this.packageInstallId, { resolve, reject });
});
this.installer.send(request);
return promise;
}
attach(projectService: ProjectService) {
this.projectService = projectService;
this.installer = this.createInstallerProcess();
}
onProjectClosed(p: Project): void {
this.installer.send({ projectName: p.getProjectName(), kind: "closeProject" });
}
enqueueInstallTypingsRequest(project: Project, typeAcquisition: TypeAcquisition, unresolvedImports: SortedReadonlyArray<string>): void {
const request = createInstallTypingsRequest(project, typeAcquisition, unresolvedImports);
if (this.logger.hasLevel(LogLevel.verbose)) {
this.logger.info(`TIAdapter:: Scheduling throttled operation:${stringifyIndented(request)}`);
}
if (this.activeRequestCount < this.maxActiveRequestCount) {
this.scheduleRequest(request);
}
else {
if (this.logger.hasLevel(LogLevel.verbose)) {
this.logger.info(`TIAdapter:: Deferring request for: ${request.projectName}`);
}
this.requestQueue.enqueue(request);
this.requestMap.set(request.projectName, request);
}
}
handleMessage(response: TypesRegistryResponse | PackageInstalledResponse | SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes | InitializationFailedResponse | server.WatchTypingLocations) {
if (this.logger.hasLevel(LogLevel.verbose)) {
this.logger.info(`TIAdapter:: Received response:${stringifyIndented(response)}`);
}
switch (response.kind) {
case EventTypesRegistry:
this.typesRegistryCache = new Map(Object.entries(response.typesRegistry));
break;
case ActionPackageInstalled: {
const promise = this.packageInstalledPromise?.get(response.id);
Debug.assertIsDefined(promise, "Should find the promise for package install");
this.packageInstalledPromise?.delete(response.id);
if (response.success) {
promise.resolve({ successMessage: response.message });
}
else {
promise.reject(response.message);
}
this.projectService.updateTypingsForProject(response);
// The behavior is the same as for setTypings, so send the same event.
this.event(response, "setTypings");
break;
}
case EventInitializationFailed: {
const body: protocol.TypesInstallerInitializationFailedEventBody = {
message: response.message,
};
const eventName: protocol.TypesInstallerInitializationFailedEventName = "typesInstallerInitializationFailed";
this.event(body, eventName);
break;
}
case EventBeginInstallTypes: {
const body: protocol.BeginInstallTypesEventBody = {
eventId: response.eventId,
packages: response.packagesToInstall,
};
const eventName: protocol.BeginInstallTypesEventName = "beginInstallTypes";
this.event(body, eventName);
break;
}
case EventEndInstallTypes: {
if (this.telemetryEnabled) {
const body: protocol.TypingsInstalledTelemetryEventBody = {
telemetryEventName: "typingsInstalled",
payload: {
installedPackages: response.packagesToInstall.join(","),
installSuccess: response.installSuccess,
typingsInstallerVersion: response.typingsInstallerVersion,
},
};
const eventName: protocol.TelemetryEventName = "telemetry";
this.event(body, eventName);
}
const body: protocol.EndInstallTypesEventBody = {
eventId: response.eventId,
packages: response.packagesToInstall,
success: response.installSuccess,
};
const eventName: protocol.EndInstallTypesEventName = "endInstallTypes";
this.event(body, eventName);
break;
}
case ActionInvalidate: {
this.projectService.updateTypingsForProject(response);
break;
}
case ActionSet: {
if (this.activeRequestCount > 0) {
this.activeRequestCount--;
}
else {
Debug.fail("TIAdapter:: Received too many responses");
}
while (!this.requestQueue.isEmpty()) {
const queuedRequest = this.requestQueue.dequeue();
if (this.requestMap.get(queuedRequest.projectName) === queuedRequest) {
this.requestMap.delete(queuedRequest.projectName);
this.scheduleRequest(queuedRequest);
break;
}
if (this.logger.hasLevel(LogLevel.verbose)) {
this.logger.info(`TIAdapter:: Skipping defunct request for: ${queuedRequest.projectName}`);
}
}
this.projectService.updateTypingsForProject(response);
this.event(response, "setTypings");
break;
}
case ActionWatchTypingLocations:
this.projectService.watchTypingLocations(response);
break;
default:
assertType<never>(response);
}
}
scheduleRequest(request: DiscoverTypings) {
if (this.logger.hasLevel(LogLevel.verbose)) {
this.logger.info(`TIAdapter:: Scheduling request for: ${request.projectName}`);
}
this.activeRequestCount++;
this.host.setTimeout(
() => {
if (this.logger.hasLevel(LogLevel.verbose)) {
this.logger.info(`TIAdapter:: Sending request:${stringifyIndented(request)}`);
}
this.installer.send(request);
},
TypingsInstallerAdapter.requestDelayMillis,
`${request.projectName}::${request.kind}`,
);
}
protected abstract createInstallerProcess(): TypingsInstallerWorkerProcess;
}