Skip to content

Commit 0c78b83

Browse files
committed
Ensure dynamically imported plugins are loaded in the correct order
1 parent f40a867 commit 0c78b83

File tree

5 files changed

+165
-29
lines changed

5 files changed

+165
-29
lines changed

src/server/editorServices.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,9 @@ namespace ts.server {
802802

803803
private performanceEventHandler?: PerformanceEventHandler;
804804

805+
private pendingPluginEnablements?: ESMap<Project, Promise<BeginEnablePluginResult>[]>;
806+
private currentPluginEnablementPromise?: Promise<void>;
807+
805808
constructor(opts: ProjectServiceOptions) {
806809
this.host = opts.host;
807810
this.logger = opts.logger;
@@ -4056,6 +4059,94 @@ namespace ts.server {
40564059
return false;
40574060
}
40584061

4062+
/*@internal*/
4063+
requestEnablePlugin(project: Project, pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined) {
4064+
if (!this.host.importServicePlugin && !this.host.require) {
4065+
this.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
4066+
return;
4067+
}
4068+
4069+
this.logger.info(`Enabling plugin ${pluginConfigEntry.name} from candidate paths: ${searchPaths.join(",")}`);
4070+
if (!pluginConfigEntry.name || parsePackageName(pluginConfigEntry.name).rest) {
4071+
this.logger.info(`Skipped loading plugin ${pluginConfigEntry.name || JSON.stringify(pluginConfigEntry)} because only package name is allowed plugin name`);
4072+
return;
4073+
}
4074+
4075+
// If the host supports dynamic import, begin enabling the plugin asynchronously.
4076+
if (this.host.importServicePlugin) {
4077+
const importPromise = project.beginEnablePluginAsync(pluginConfigEntry, searchPaths, pluginConfigOverrides);
4078+
this.pendingPluginEnablements ??= new Map();
4079+
let promises = this.pendingPluginEnablements.get(project);
4080+
if (!promises) this.pendingPluginEnablements.set(project, promises = []);
4081+
promises.push(importPromise);
4082+
return;
4083+
}
4084+
4085+
// Otherwise, load the plugin using `resolve`
4086+
project.endEnablePlugin(project.beginEnablePluginSync(pluginConfigEntry, searchPaths, pluginConfigOverrides));
4087+
}
4088+
4089+
/**
4090+
* Waits for any ongoing plugin enablement requests to complete.
4091+
*/
4092+
/* @internal */
4093+
async waitForPendingPlugins() {
4094+
while (this.currentPluginEnablementPromise) {
4095+
await this.currentPluginEnablementPromise;
4096+
}
4097+
}
4098+
4099+
/**
4100+
* Starts enabling any requested plugins without waiting for the result.
4101+
*/
4102+
/* @internal */
4103+
enableRequestedPlugins() {
4104+
if (this.pendingPluginEnablements) {
4105+
void this.enableRequestedPluginsAsync();
4106+
}
4107+
}
4108+
4109+
private async enableRequestedPluginsAsync() {
4110+
// If we're already enabling plugins, wait for any existing operations to complete
4111+
await this.waitForPendingPlugins();
4112+
4113+
// Skip if there are no new plugin enablement requests
4114+
if (!this.pendingPluginEnablements) {
4115+
return;
4116+
}
4117+
4118+
// Consume the pending plugin enablement requests
4119+
const entries = arrayFrom(this.pendingPluginEnablements.entries());
4120+
this.pendingPluginEnablements = undefined;
4121+
4122+
// Start processing the requests, keeping track of the promise for the operation so that
4123+
// project consumers can potentially wait for the plugins to load.
4124+
this.currentPluginEnablementPromise = this.enableRequestedPluginsWorker(entries);
4125+
await this.currentPluginEnablementPromise;
4126+
}
4127+
4128+
private async enableRequestedPluginsWorker(pendingPlugins: [Project, Promise<BeginEnablePluginResult>[]][]) {
4129+
// This should only be called from `enableRequestedServicePlugins`, which ensures this precondition is met.
4130+
Debug.assert(this.currentPluginEnablementPromise === undefined);
4131+
4132+
// Process all pending plugins, partitioned by project. This way a project with few plugins doesn't need to wait
4133+
// on a project with many plugins.
4134+
await Promise.all(map(pendingPlugins, ([project, promises]) => this.enableRequestedPluginsForProjectAsync(project, promises)));
4135+
4136+
// Clear the pending operation and notify the client that projects have been updated.
4137+
this.currentPluginEnablementPromise = undefined;
4138+
this.sendProjectsUpdatedInBackgroundEvent();
4139+
}
4140+
4141+
private async enableRequestedPluginsForProjectAsync(project: Project, promises: Promise<BeginEnablePluginResult>[]) {
4142+
// Await all pending plugin imports. This ensures all requested plugin modules are fully loaded
4143+
// prior to patching the language service.
4144+
const results = await Promise.all(promises);
4145+
for (const result of results) {
4146+
project.endEnablePlugin(result);
4147+
}
4148+
}
4149+
40594150
configurePlugin(args: protocol.ConfigurePluginRequestArguments) {
40604151
// For any projects that already have the plugin loaded, configure the plugin
40614152
this.forEachEnabledProject(project => project.onPluginConfigurationChanged(args.pluginName, args.configuration));

src/server/project.ts

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ namespace ts.server {
101101

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

104+
/* @internal */
105+
export interface BeginEnablePluginResult {
106+
pluginConfigEntry: PluginImport;
107+
pluginConfigOverrides: Map<any> | undefined;
108+
resolvedModule: PluginModuleFactory | undefined;
109+
errorLogs: string[] | undefined;
110+
}
111+
104112
/**
105113
* The project root can be script info - if root is present,
106114
* or it could be just normalized path if root wasn't present on the host(only for non inferred project)
@@ -133,6 +141,7 @@ namespace ts.server {
133141
private externalFiles: SortedReadonlyArray<string> | undefined;
134142
private missingFilesMap: ESMap<Path, FileWatcher> | undefined;
135143
private generatedFilesMap: GeneratedFileWatcherMap | undefined;
144+
136145
private plugins: PluginModuleWithName[] = [];
137146

138147
/*@internal*/
@@ -1545,10 +1554,10 @@ namespace ts.server {
15451554
return !!this.program && this.program.isSourceOfProjectReferenceRedirect(fileName);
15461555
}
15471556

1548-
protected async enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map<any> | undefined): Promise<void> {
1557+
protected enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map<any> | undefined): void {
15491558
const host = this.projectService.host;
15501559

1551-
if (!host.require) {
1560+
if (!host.require && !host.importServicePlugin) {
15521561
this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
15531562
return;
15541563
}
@@ -1572,43 +1581,58 @@ namespace ts.server {
15721581
// Provide global: true so plugins can detect why they can't find their config
15731582
this.projectService.logger.info(`Loading global plugin ${globalPluginName}`);
15741583

1575-
await this.enablePlugin({ name: globalPluginName, global: true } as PluginImport, searchPaths, pluginConfigOverrides);
1584+
this.enablePlugin({ name: globalPluginName, global: true } as PluginImport, searchPaths, pluginConfigOverrides);
15761585
}
15771586
}
15781587
}
15791588

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

1587-
const log = (message: string) => this.projectService.logger.info(message);
15881596
let errorLogs: string[] | undefined;
1589-
const logError = (message: string) => {
1590-
(errorLogs || (errorLogs = [])).push(message);
1591-
};
1597+
const log = (message: string) => this.projectService.logger.info(message);
1598+
const logError = (message: string) => { (errorLogs ??= []).push(message); };
1599+
const resolvedModule = firstDefined(searchPaths, searchPath =>
1600+
Project.resolveModule(pluginConfigEntry.name, searchPath, this.projectService.host, log, logError) as PluginModuleFactory | undefined);
1601+
return { pluginConfigEntry, pluginConfigOverrides, resolvedModule, errorLogs };
1602+
}
1603+
1604+
/**
1605+
* Performs the initial steps of enabling a plugin by finding and instantiating the module for a plugin asynchronously using dynamic `import`.
1606+
*/
1607+
/*@internal*/
1608+
async beginEnablePluginAsync(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined): Promise<BeginEnablePluginResult> {
1609+
Debug.assertIsDefined(this.projectService.host.importServicePlugin);
15921610

1593-
let resolvedModule: any | undefined;
1594-
if (this.projectService.host.importServicePlugin) {
1595-
for (const searchPath of searchPaths) {
1611+
let errorLogs: string[] | undefined;
1612+
let resolvedModule: PluginModuleFactory | undefined;
1613+
for (const searchPath of searchPaths) {
1614+
try {
15961615
const result = await this.projectService.host.importServicePlugin(searchPath, pluginConfigEntry.name);
15971616
if (result.error) {
1598-
logError(result.error.toString());
1617+
(errorLogs ??= []).push(result.error.toString());
15991618
}
16001619
else {
1601-
resolvedModule = result.module;
1620+
resolvedModule = result.module as PluginModuleFactory;
16021621
break;
16031622
}
1604-
1623+
}
1624+
catch (e) {
1625+
(errorLogs ??= []).push(`${e}`);
16051626
}
16061627
}
1607-
else {
1608-
resolvedModule = firstDefined(searchPaths, searchPath =>
1609-
Project.resolveModule(pluginConfigEntry.name, searchPath, this.projectService.host, log, logError) as PluginModuleFactory | undefined);
1610-
}
1628+
return { pluginConfigEntry, pluginConfigOverrides, resolvedModule, errorLogs };
1629+
}
16111630

1631+
/**
1632+
* Performs the remaining steps of enabling a plugin after its module has been instantiated.
1633+
*/
1634+
/*@internal*/
1635+
endEnablePlugin({ pluginConfigEntry, pluginConfigOverrides, resolvedModule, errorLogs }: BeginEnablePluginResult) {
16121636
if (resolvedModule) {
16131637
const configurationOverride = pluginConfigOverrides && pluginConfigOverrides.get(pluginConfigEntry.name);
16141638
if (configurationOverride) {
@@ -1621,11 +1645,15 @@ namespace ts.server {
16211645
this.enableProxy(resolvedModule, pluginConfigEntry);
16221646
}
16231647
else {
1624-
forEach(errorLogs, log);
1648+
forEach(errorLogs, message => this.projectService.logger.info(message));
16251649
this.projectService.logger.info(`Couldn't find ${pluginConfigEntry.name}`);
16261650
}
16271651
}
16281652

1653+
protected enablePlugin(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined): void {
1654+
this.projectService.requestEnablePlugin(this, pluginConfigEntry, searchPaths, pluginConfigOverrides);
1655+
}
1656+
16291657
private enableProxy(pluginModuleFactory: PluginModuleFactory, configEntry: PluginImport) {
16301658
try {
16311659
if (typeof pluginModuleFactory !== "function") {
@@ -2289,10 +2317,10 @@ namespace ts.server {
22892317
}
22902318

22912319
/*@internal*/
2292-
async enablePluginsWithOptions(options: CompilerOptions, pluginConfigOverrides: ESMap<string, any> | undefined): Promise<void> {
2320+
enablePluginsWithOptions(options: CompilerOptions, pluginConfigOverrides: ESMap<string, any> | undefined): void {
22932321
const host = this.projectService.host;
22942322

2295-
if (!host.require) {
2323+
if (!host.require && !host.importServicePlugin) {
22962324
this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
22972325
return;
22982326
}
@@ -2310,7 +2338,7 @@ namespace ts.server {
23102338
// Enable tsconfig-specified plugins
23112339
if (options.plugins) {
23122340
for (const pluginConfigEntry of options.plugins) {
2313-
await this.enablePlugin(pluginConfigEntry, searchPaths, pluginConfigOverrides);
2341+
this.enablePlugin(pluginConfigEntry, searchPaths, pluginConfigOverrides);
23142342
}
23152343
}
23162344

src/server/session.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3024,7 +3024,9 @@ namespace ts.server {
30243024
public executeCommand(request: protocol.Request): HandlerResponse {
30253025
const handler = this.handlers.get(request.command);
30263026
if (handler) {
3027-
return this.executeWithRequestId(request.seq, () => handler(request));
3027+
const response = this.executeWithRequestId(request.seq, () => handler(request));
3028+
this.projectService.enableRequestedPlugins();
3029+
return response;
30283030
}
30293031
else {
30303032
this.logger.msg(`Unrecognized JSON command:${stringifyIndented(request)}`, Msg.Err);

src/webServer/webServer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,6 @@ namespace ts.server {
162162
clearImmediate: handle => clearTimeout(handle),
163163
/* eslint-enable no-restricted-globals */
164164

165-
require: () => ({ module: undefined, error: new Error("Not implemented") }),
166165
importServicePlugin: async (root: string, moduleName: string): Promise<ImportPluginResult> => {
167166
const packageRoot = combinePaths(root, "node_modules", moduleName);
168167

tests/baselines/reference/api/tsserverlibrary.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6844,6 +6844,16 @@ declare namespace ts.server {
68446844
message?: string;
68456845
};
68466846
};
6847+
type ImportPluginResult = {
6848+
module: {};
6849+
error: undefined;
6850+
} | {
6851+
module: undefined;
6852+
error: {
6853+
stack?: string;
6854+
message: string;
6855+
};
6856+
};
68476857
interface ServerHost extends System {
68486858
watchFile(path: string, callback: FileWatcherCallback, pollingInterval?: number, options?: WatchOptions): FileWatcher;
68496859
watchDirectory(path: string, callback: DirectoryWatcherCallback, recursive?: boolean, options?: WatchOptions): FileWatcher;
@@ -6854,6 +6864,7 @@ declare namespace ts.server {
68546864
gc?(): void;
68556865
trace?(s: string): void;
68566866
require?(initialPath: string, moduleName: string): RequireResult;
6867+
importServicePlugin?(root: string, moduleName: string): Promise<ImportPluginResult>;
68576868
}
68586869
}
68596870
declare namespace ts.server {
@@ -10255,6 +10266,8 @@ declare namespace ts.server {
1025510266
/** Tracks projects that we have already sent telemetry for. */
1025610267
private readonly seenProjects;
1025710268
private performanceEventHandler?;
10269+
private pendingPluginEnablements?;
10270+
private currentPluginEnablementPromise?;
1025810271
constructor(opts: ProjectServiceOptions);
1025910272
toPath(fileName: string): Path;
1026010273
private loadTypesMap;
@@ -10414,6 +10427,9 @@ declare namespace ts.server {
1041410427
applySafeList(proj: protocol.ExternalProject): NormalizedPath[];
1041510428
openExternalProject(proj: protocol.ExternalProject): void;
1041610429
hasDeferredExtension(): boolean;
10430+
private enableRequestedPluginsAsync;
10431+
private enableRequestedPluginsWorker;
10432+
private enableRequestedPluginsForProjectAsync;
1041710433
configurePlugin(args: protocol.ConfigurePluginRequestArguments): void;
1041810434
}
1041910435
export {};

0 commit comments

Comments
 (0)