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 buildDynamicImportCompat = () => buildProject("src/dynamicImportCompat", cmdLineOptions);
task("dynamicImportCompat", buildDynamicImportCompat);

const buildServerMain = () => buildProject("src/tsserver", cmdLineOptions);
const buildServer = series(buildDynamicImportCompat, 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 cleanDynamicImportCompat = () => cleanProject("src/dynamicImportCompat");
const cleanServerMain = () => cleanProject("src/tsserver");
const cleanServer = series(cleanDynamicImportCompat, cleanServerMain);
cleanServer.displayName = "cleanServer";
cleanTasks.push(cleanServer);
task("clean-tsserver", cleanServer);
task("clean-tsserver").description = "Cleans outputs for the language server";

const watchDynamicImportCompat = () => watchProject("src/dynamicImportCompat", cmdLineOptions);
const watchServer = () => watchProject("src/tsserver", cmdLineOptions);
task("watch-tsserver", series(preBuild, parallel(watchLib, watchDiagnostics, watchServer)));
task("watch-tsserver", series(preBuild, parallel(watchLib, watchDiagnostics, watchDynamicImportCompat, 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/dynamicImportCompat.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("dynamicImportCompat.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
3 changes: 3 additions & 0 deletions src/dynamicImportCompat/dynamicImportCompat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace ts.server {
export const dynamicImport = (id: string) => import(id);
}
16 changes: 16 additions & 0 deletions src/dynamicImportCompat/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "../tsconfig-library-base",
"compilerOptions": {
"outDir": "../../built/local",
"rootDir": ".",
"target": "esnext",
"module": "esnext",
"lib": ["esnext"],
"declaration": false,
"sourceMap": true,
"tsBuildInfoFile": "../../built/local/dynamicImportCompat.tsbuildinfo"
},
"files": [
"dynamicImportCompat.ts",
]
}
16 changes: 16 additions & 0 deletions src/harness/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,20 @@ namespace Utils {
value === undefined ? "undefined" :
JSON.stringify(value);
}

export interface Deferred<T> {
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason: unknown) => void;
promise: Promise<T>;
}

export function defer<T = void>(): Deferred<T> {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { resolve, reject, promise };
}
}
111 changes: 111 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,114 @@ 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 `require`
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, and that any promise rejections are observed.
const results = await Promise.all(promises);
if (project.isClosed()) {
// project is not alive, so don't enable plugins.
return;
}

for (const result of results) {
project.endEnablePlugin(result);
}

// Plugins may have modified external files, so mark the project as dirty.
this.delayUpdateProjectGraph(project);
}

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
98 changes: 81 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 @@ -241,6 +250,26 @@ namespace ts.server {
return result.module;
}

/*@internal*/
public static async importServicePluginAsync(moduleName: string, initialDir: string, host: ServerHost, log: (message: string) => void, logErrors?: (message: string) => void): Promise<{} | undefined> {
Debug.assertIsDefined(host.importServicePlugin);
const resolvedPath = combinePaths(initialDir, "node_modules");
log(`Dynamically importing ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`);
let result: ModuleImportResult;
try {
result = await host.importServicePlugin(resolvedPath, moduleName);
}
catch (e) {
result = { module: undefined, error: e };
}
if (result.error) {
const err = result.error.stack || result.error.message || JSON.stringify(result.error);
(logErrors || log)(`Failed to dynamically import module '${moduleName}' from ${resolvedPath}: ${err}`);
return undefined;
}
return result.module;
}

/*@internal*/
readonly currentDirectory: string;

Expand Down Expand Up @@ -1545,19 +1574,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 +1606,51 @@ 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;
const log = (message: string) => this.projectService.logger.info(message);
const logError = (message: string) => {
(errorLogs ??= []).push(message);
};

let resolvedModule: PluginModuleFactory | undefined;
for (const searchPath of searchPaths) {
resolvedModule = await Project.importServicePluginAsync(pluginConfigEntry.name, searchPath, this.projectService.host, log, logError) as PluginModuleFactory | undefined;
if (resolvedModule !== undefined) {
break;
}
}
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 +1663,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 +2335,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 +2360,7 @@ namespace ts.server {
}
}

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

/**
Expand Down
Loading