Skip to content

Enable TS Server plugins on web #47377

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 14, 2022
16 changes: 13 additions & 3 deletions Gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,20 +214,29 @@ task("watch-services").flags = {
" --built": "Compile using the built version of the compiler."
};

const buildServer = () => buildProject("src/tsserver", cmdLineOptions);
const buildServerWeb = () => buildProject("src/tsserverWeb", cmdLineOptions);
task("tsserverWeb", buildServerWeb);

const buildServerMain = () => buildProject("src/tsserver", cmdLineOptions);
const buildServer = series(buildServerWeb, buildServerMain);
buildServer.displayName = "buildServer";
task("tsserver", series(preBuild, buildServer));
task("tsserver").description = "Builds the language server";
task("tsserver").flags = {
" --built": "Compile using the built version of the compiler."
};

const cleanServer = () => cleanProject("src/tsserver");
const cleanServerWeb = () => cleanProject("src/tsserverWeb");
const cleanServerMain = () => cleanProject("src/tsserver");
const cleanServer = series(cleanServerWeb, cleanServerMain);
cleanServer.displayName = "cleanServer";
cleanTasks.push(cleanServer);
task("clean-tsserver", cleanServer);
task("clean-tsserver").description = "Cleans outputs for the language server";

const watchServerWeb = () => watchProject("src/tsserverWeb", cmdLineOptions);
const watchServer = () => watchProject("src/tsserver", cmdLineOptions);
task("watch-tsserver", series(preBuild, parallel(watchLib, watchDiagnostics, watchServer)));
task("watch-tsserver", series(preBuild, parallel(watchLib, watchDiagnostics, watchServerWeb, watchServer)));
task("watch-tsserver").description = "Watch for changes and rebuild the language server only";
task("watch-tsserver").flags = {
" --built": "Compile using the built version of the compiler."
Expand Down Expand Up @@ -549,6 +558,7 @@ const produceLKG = async () => {
"built/local/typescriptServices.js",
"built/local/typescriptServices.d.ts",
"built/local/tsserver.js",
"built/local/tsserverWeb.js",
"built/local/typescript.js",
"built/local/typescript.d.ts",
"built/local/tsserverlibrary.js",
Expand Down
1 change: 1 addition & 0 deletions scripts/produceLKG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ async function copyScriptOutputs() {
await copyWithCopyright("cancellationToken.js");
await copyWithCopyright("tsc.release.js", "tsc.js");
await copyWithCopyright("tsserver.js");
await copyWithCopyright("tsserverWeb.js");
await copyFromBuiltLocal("tsserverlibrary.js"); // copyright added by build
await copyFromBuiltLocal("typescript.js"); // copyright added by build
await copyFromBuiltLocal("typescriptServices.js"); // copyright added by build
Expand Down
103 changes: 103 additions & 0 deletions src/server/editorServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,9 @@ namespace ts.server {

private performanceEventHandler?: PerformanceEventHandler;

private pendingPluginEnablements?: ESMap<Project, Promise<BeginEnablePluginResult>[]>;
private currentPluginEnablementPromise?: Promise<void>;

constructor(opts: ProjectServiceOptions) {
this.host = opts.host;
this.logger = opts.logger;
Expand Down Expand Up @@ -4056,6 +4059,106 @@ namespace ts.server {
return false;
}

/*@internal*/
requestEnablePlugin(project: Project, pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined) {
if (!this.host.importServicePlugin && !this.host.require) {
this.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
return;
}

this.logger.info(`Enabling plugin ${pluginConfigEntry.name} from candidate paths: ${searchPaths.join(",")}`);
if (!pluginConfigEntry.name || parsePackageName(pluginConfigEntry.name).rest) {
this.logger.info(`Skipped loading plugin ${pluginConfigEntry.name || JSON.stringify(pluginConfigEntry)} because only package name is allowed plugin name`);
return;
}

// If the host supports dynamic import, begin enabling the plugin asynchronously.
if (this.host.importServicePlugin) {
const importPromise = project.beginEnablePluginAsync(pluginConfigEntry, searchPaths, pluginConfigOverrides);
this.pendingPluginEnablements ??= new Map();
let promises = this.pendingPluginEnablements.get(project);
if (!promises) this.pendingPluginEnablements.set(project, promises = []);
Copy link
Member

Choose a reason for hiding this comment

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

Handle this map when project closes?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure it should matter. This map is built up during the course of a single request, and then is set to undefined as soon as the request completes processing (in enableRequestedPluginsAsync) so it won't hold a reference to the project for very long. The Promise for the plugin import actually has a longer lifetime than the key in the map, and removing the key early would just mean that we may fail to observe a rejected promise later.

promises.push(importPromise);
return;
}

// Otherwise, load the plugin using `resolve`
project.endEnablePlugin(project.beginEnablePluginSync(pluginConfigEntry, searchPaths, pluginConfigOverrides));
}

/* @internal */
hasNewPluginEnablementRequests() {
return !!this.pendingPluginEnablements;
}

/* @internal */
hasPendingPluginEnablements() {
return !!this.currentPluginEnablementPromise;
}

/**
* Waits for any ongoing plugin enablement requests to complete.
*/
/* @internal */
async waitForPendingPlugins() {
while (this.currentPluginEnablementPromise) {
await this.currentPluginEnablementPromise;
}
}

/**
* Starts enabling any requested plugins without waiting for the result.
*/
/* @internal */
enableRequestedPlugins() {
if (this.pendingPluginEnablements) {
void this.enableRequestedPluginsAsync();
}
}

private async enableRequestedPluginsAsync() {
if (this.currentPluginEnablementPromise) {
// If we're already enabling plugins, wait for any existing operations to complete
await this.waitForPendingPlugins();
}

// Skip if there are no new plugin enablement requests
if (!this.pendingPluginEnablements) {
return;
}

// Consume the pending plugin enablement requests
const entries = arrayFrom(this.pendingPluginEnablements.entries());
this.pendingPluginEnablements = undefined;

// Start processing the requests, keeping track of the promise for the operation so that
// project consumers can potentially wait for the plugins to load.
this.currentPluginEnablementPromise = this.enableRequestedPluginsWorker(entries);
await this.currentPluginEnablementPromise;
}

private async enableRequestedPluginsWorker(pendingPlugins: [Project, Promise<BeginEnablePluginResult>[]][]) {
// This should only be called from `enableRequestedPluginsAsync`, which ensures this precondition is met.
Debug.assert(this.currentPluginEnablementPromise === undefined);

// Process all pending plugins, partitioned by project. This way a project with few plugins doesn't need to wait
// on a project with many plugins.
await Promise.all(map(pendingPlugins, ([project, promises]) => this.enableRequestedPluginsForProjectAsync(project, promises)));

// Clear the pending operation and notify the client that projects have been updated.
this.currentPluginEnablementPromise = undefined;
this.sendProjectsUpdatedInBackgroundEvent();
}

private async enableRequestedPluginsForProjectAsync(project: Project, promises: Promise<BeginEnablePluginResult>[]) {
// Await all pending plugin imports. This ensures all requested plugin modules are fully loaded
// prior to patching the language service.
const results = await Promise.all(promises);
for (const result of results) {
project.endEnablePlugin(result);
}
}

configurePlugin(args: protocol.ConfigurePluginRequestArguments) {
// For any projects that already have the plugin loaded, configure the plugin
this.forEachEnabledProject(project => project.onPluginConfigurationChanged(args.pluginName, args.configuration));
Expand Down
82 changes: 65 additions & 17 deletions src/server/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ namespace ts.server {

export type PluginModuleFactory = (mod: { typescript: typeof ts }) => PluginModule;

/* @internal */
export interface BeginEnablePluginResult {
pluginConfigEntry: PluginImport;
pluginConfigOverrides: Map<any> | undefined;
resolvedModule: PluginModuleFactory | undefined;
errorLogs: string[] | undefined;
}

/**
* The project root can be script info - if root is present,
* or it could be just normalized path if root wasn't present on the host(only for non inferred project)
Expand Down Expand Up @@ -133,6 +141,7 @@ namespace ts.server {
private externalFiles: SortedReadonlyArray<string> | undefined;
private missingFilesMap: ESMap<Path, FileWatcher> | undefined;
private generatedFilesMap: GeneratedFileWatcherMap | undefined;

private plugins: PluginModuleWithName[] = [];

/*@internal*/
Expand Down Expand Up @@ -1545,19 +1554,19 @@ namespace ts.server {
return !!this.program && this.program.isSourceOfProjectReferenceRedirect(fileName);
}

protected enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map<any> | undefined) {
protected enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map<any> | undefined): void {
const host = this.projectService.host;

if (!host.require) {
if (!host.require && !host.importServicePlugin) {
this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
return;
}

// Search any globally-specified probe paths, then our peer node_modules
const searchPaths = [
...this.projectService.pluginProbeLocations,
// ../../.. to walk from X/node_modules/typescript/lib/tsserver.js to X/node_modules/
combinePaths(this.projectService.getExecutingFilePath(), "../../.."),
...this.projectService.pluginProbeLocations,
// ../../.. to walk from X/node_modules/typescript/lib/tsserver.js to X/node_modules/
combinePaths(this.projectService.getExecutingFilePath(), "../../.."),
];

if (this.projectService.globalPlugins) {
Expand All @@ -1577,20 +1586,55 @@ namespace ts.server {
}
}

protected enablePlugin(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined) {
this.projectService.logger.info(`Enabling plugin ${pluginConfigEntry.name} from candidate paths: ${searchPaths.join(",")}`);
if (!pluginConfigEntry.name || parsePackageName(pluginConfigEntry.name).rest) {
this.projectService.logger.info(`Skipped loading plugin ${pluginConfigEntry.name || JSON.stringify(pluginConfigEntry)} because only package name is allowed plugin name`);
return;
}
/**
* Performs the initial steps of enabling a plugin by finding and instantiating the module for a plugin synchronously using 'require'.
*/
/*@internal*/
beginEnablePluginSync(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined): BeginEnablePluginResult {
Debug.assertIsDefined(this.projectService.host.require);

const log = (message: string) => this.projectService.logger.info(message);
let errorLogs: string[] | undefined;
const log = (message: string) => this.projectService.logger.info(message);
const logError = (message: string) => {
(errorLogs || (errorLogs = [])).push(message);
(errorLogs ??= []).push(message);
};
const resolvedModule = firstDefined(searchPaths, searchPath =>
Project.resolveModule(pluginConfigEntry.name, searchPath, this.projectService.host, log, logError) as PluginModuleFactory | undefined);
return { pluginConfigEntry, pluginConfigOverrides, resolvedModule, errorLogs };
}

/**
* Performs the initial steps of enabling a plugin by finding and instantiating the module for a plugin asynchronously using dynamic `import`.
*/
/*@internal*/
async beginEnablePluginAsync(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined): Promise<BeginEnablePluginResult> {
Debug.assertIsDefined(this.projectService.host.importServicePlugin);

let errorLogs: string[] | undefined;
let resolvedModule: PluginModuleFactory | undefined;
for (const searchPath of searchPaths) {
try {
const result = await this.projectService.host.importServicePlugin(searchPath, pluginConfigEntry.name);
if (result.error) {
(errorLogs ??= []).push(result.error.toString());
}
else {
resolvedModule = result.module as PluginModuleFactory;
break;
}
}
catch (e) {
(errorLogs ??= []).push(`${e}`);
}
}
return { pluginConfigEntry, pluginConfigOverrides, resolvedModule, errorLogs };
}

/**
* Performs the remaining steps of enabling a plugin after its module has been instantiated.
*/
/*@internal*/
endEnablePlugin({ pluginConfigEntry, pluginConfigOverrides, resolvedModule, errorLogs }: BeginEnablePluginResult) {
if (resolvedModule) {
const configurationOverride = pluginConfigOverrides && pluginConfigOverrides.get(pluginConfigEntry.name);
if (configurationOverride) {
Expand All @@ -1603,11 +1647,15 @@ namespace ts.server {
this.enableProxy(resolvedModule, pluginConfigEntry);
}
else {
forEach(errorLogs, log);
forEach(errorLogs, message => this.projectService.logger.info(message));
this.projectService.logger.info(`Couldn't find ${pluginConfigEntry.name}`);
}
}

protected enablePlugin(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined): void {
this.projectService.requestEnablePlugin(this, pluginConfigEntry, searchPaths, pluginConfigOverrides);
}

private enableProxy(pluginModuleFactory: PluginModuleFactory, configEntry: PluginImport) {
try {
if (typeof pluginModuleFactory !== "function") {
Expand Down Expand Up @@ -2271,10 +2319,10 @@ namespace ts.server {
}

/*@internal*/
enablePluginsWithOptions(options: CompilerOptions, pluginConfigOverrides: ESMap<string, any> | undefined) {
enablePluginsWithOptions(options: CompilerOptions, pluginConfigOverrides: ESMap<string, any> | undefined): void {
const host = this.projectService.host;

if (!host.require) {
if (!host.require && !host.importServicePlugin) {
this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
return;
}
Expand All @@ -2296,7 +2344,7 @@ namespace ts.server {
}
}

this.enableGlobalPlugins(options, pluginConfigOverrides);
return this.enableGlobalPlugins(options, pluginConfigOverrides);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions src/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,6 @@ namespace ts.server {
CommandNames.OrganizeImportsFull,
CommandNames.GetEditsForFileRename,
CommandNames.GetEditsForFileRenameFull,
CommandNames.ConfigurePlugin,
CommandNames.PrepareCallHierarchy,
CommandNames.ProvideCallHierarchyIncomingCalls,
CommandNames.ProvideCallHierarchyOutgoingCalls,
Expand Down Expand Up @@ -3025,7 +3024,9 @@ namespace ts.server {
public executeCommand(request: protocol.Request): HandlerResponse {
const handler = this.handlers.get(request.command);
if (handler) {
return this.executeWithRequestId(request.seq, () => handler(request));
const response = this.executeWithRequestId(request.seq, () => handler(request));
this.projectService.enableRequestedPlugins();
return response;
}
else {
this.logger.msg(`Unrecognized JSON command:${stringifyIndented(request)}`, Msg.Err);
Expand Down
3 changes: 3 additions & 0 deletions src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ declare namespace ts.server {
}

export type RequireResult = { module: {}, error: undefined } | { module: undefined, error: { stack?: string, message?: string } };
export type ImportPluginResult = { module: {}, error: undefined } | { module: undefined, error: { stack?: string, message: string } };

export interface ServerHost extends System {
watchFile(path: string, callback: FileWatcherCallback, pollingInterval?: number, options?: WatchOptions): FileWatcher;
watchDirectory(path: string, callback: DirectoryWatcherCallback, recursive?: boolean, options?: WatchOptions): FileWatcher;
Expand All @@ -16,5 +18,6 @@ declare namespace ts.server {
gc?(): void;
trace?(s: string): void;
require?(initialPath: string, moduleName: string): RequireResult;
importServicePlugin?(root: string, moduleName: string): Promise<ImportPluginResult>;
Copy link
Member

Choose a reason for hiding this comment

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

do we need different name here? Especially if in future we have compile time plugins that get used on compile on save etc ?
May be just import like require ?

Copy link
Contributor

Choose a reason for hiding this comment

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

@mjbvz and I discussed that earlier. This isn't as generic as import because there is specific logic for importing a browser plugin inside that function.

}
}
Loading