Skip to content

Commit 3fc5f96

Browse files
mjbvzrbucktonsandersn
authored
Enable TS Server plugins on web (#47377)
* Prototype TS plugins on web This prototype allows service plugins to be loaded on web TSServer Main changes: - Adds a new host entryPoint called `importServicePlugin` for overriding how plugins can be loaded. This may be async - Implement `importServicePlugin` for webServer - The web server plugin implementation looks for a `browser` field in the plugin's `package.json` - It then uses `import(...)` to load the plugin (the plugin source must be compiled to support being loaded as a module) * use default export from plugins This more or less matches how node plugins expect the plugin module to be an init function * Allow configure plugin requests against any web servers in partial semantic mode * Addressing some comments - Use result value instead of try/catch (`ImportPluginResult`) - Add awaits - Add logging * add tsserverWeb to patch in dynamic import * Remove eval We should throw instead when dynamic import is not implemented * Ensure dynamically imported plugins are loaded in the correct order * Add tests for async service plugin timing * Update src/server/editorServices.ts Co-authored-by: Nathan Shively-Sanders <[email protected]> * Partial PR feedback * Rename tsserverWeb to dynamicImportCompat * Additional PR feedback Co-authored-by: Ron Buckton <[email protected]> Co-authored-by: Nathan Shively-Sanders <[email protected]>
1 parent 29dffc3 commit 3fc5f96

File tree

14 files changed

+519
-30
lines changed

14 files changed

+519
-30
lines changed

Gulpfile.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,20 +214,29 @@ task("watch-services").flags = {
214214
" --built": "Compile using the built version of the compiler."
215215
};
216216

217-
const buildServer = () => buildProject("src/tsserver", cmdLineOptions);
217+
const buildDynamicImportCompat = () => buildProject("src/dynamicImportCompat", cmdLineOptions);
218+
task("dynamicImportCompat", buildDynamicImportCompat);
219+
220+
const buildServerMain = () => buildProject("src/tsserver", cmdLineOptions);
221+
const buildServer = series(buildDynamicImportCompat, buildServerMain);
222+
buildServer.displayName = "buildServer";
218223
task("tsserver", series(preBuild, buildServer));
219224
task("tsserver").description = "Builds the language server";
220225
task("tsserver").flags = {
221226
" --built": "Compile using the built version of the compiler."
222227
};
223228

224-
const cleanServer = () => cleanProject("src/tsserver");
229+
const cleanDynamicImportCompat = () => cleanProject("src/dynamicImportCompat");
230+
const cleanServerMain = () => cleanProject("src/tsserver");
231+
const cleanServer = series(cleanDynamicImportCompat, cleanServerMain);
232+
cleanServer.displayName = "cleanServer";
225233
cleanTasks.push(cleanServer);
226234
task("clean-tsserver", cleanServer);
227235
task("clean-tsserver").description = "Cleans outputs for the language server";
228236

237+
const watchDynamicImportCompat = () => watchProject("src/dynamicImportCompat", cmdLineOptions);
229238
const watchServer = () => watchProject("src/tsserver", cmdLineOptions);
230-
task("watch-tsserver", series(preBuild, parallel(watchLib, watchDiagnostics, watchServer)));
239+
task("watch-tsserver", series(preBuild, parallel(watchLib, watchDiagnostics, watchDynamicImportCompat, watchServer)));
231240
task("watch-tsserver").description = "Watch for changes and rebuild the language server only";
232241
task("watch-tsserver").flags = {
233242
" --built": "Compile using the built version of the compiler."
@@ -549,6 +558,7 @@ const produceLKG = async () => {
549558
"built/local/typescriptServices.js",
550559
"built/local/typescriptServices.d.ts",
551560
"built/local/tsserver.js",
561+
"built/local/dynamicImportCompat.js",
552562
"built/local/typescript.js",
553563
"built/local/typescript.d.ts",
554564
"built/local/tsserverlibrary.js",

scripts/produceLKG.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ async function copyScriptOutputs() {
6262
await copyWithCopyright("cancellationToken.js");
6363
await copyWithCopyright("tsc.release.js", "tsc.js");
6464
await copyWithCopyright("tsserver.js");
65+
await copyWithCopyright("dynamicImportCompat.js");
6566
await copyFromBuiltLocal("tsserverlibrary.js"); // copyright added by build
6667
await copyFromBuiltLocal("typescript.js"); // copyright added by build
6768
await copyFromBuiltLocal("typescriptServices.js"); // copyright added by build
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace ts.server {
2+
export const dynamicImport = (id: string) => import(id);
3+
}

src/dynamicImportCompat/tsconfig.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"extends": "../tsconfig-library-base",
3+
"compilerOptions": {
4+
"outDir": "../../built/local",
5+
"rootDir": ".",
6+
"target": "esnext",
7+
"module": "esnext",
8+
"lib": ["esnext"],
9+
"declaration": false,
10+
"sourceMap": true,
11+
"tsBuildInfoFile": "../../built/local/dynamicImportCompat.tsbuildinfo"
12+
},
13+
"files": [
14+
"dynamicImportCompat.ts",
15+
]
16+
}

src/harness/util.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,20 @@ namespace Utils {
109109
value === undefined ? "undefined" :
110110
JSON.stringify(value);
111111
}
112+
113+
export interface Deferred<T> {
114+
resolve: (value: T | PromiseLike<T>) => void;
115+
reject: (reason: unknown) => void;
116+
promise: Promise<T>;
117+
}
118+
119+
export function defer<T = void>(): Deferred<T> {
120+
let resolve!: (value: T | PromiseLike<T>) => void;
121+
let reject!: (reason: unknown) => void;
122+
const promise = new Promise<T>((_resolve, _reject) => {
123+
resolve = _resolve;
124+
reject = _reject;
125+
});
126+
return { resolve, reject, promise };
127+
}
112128
}

src/server/editorServices.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,9 @@ namespace ts.server {
804804

805805
private performanceEventHandler?: PerformanceEventHandler;
806806

807+
private pendingPluginEnablements?: ESMap<Project, Promise<BeginEnablePluginResult>[]>;
808+
private currentPluginEnablementPromise?: Promise<void>;
809+
807810
constructor(opts: ProjectServiceOptions) {
808811
this.host = opts.host;
809812
this.logger = opts.logger;
@@ -4063,6 +4066,114 @@ namespace ts.server {
40634066
return false;
40644067
}
40654068

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

src/server/project.ts

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ namespace ts.server {
102102

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

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

139148
/*@internal*/
@@ -245,6 +254,26 @@ namespace ts.server {
245254
return result.module;
246255
}
247256

257+
/*@internal*/
258+
public static async importServicePluginAsync(moduleName: string, initialDir: string, host: ServerHost, log: (message: string) => void, logErrors?: (message: string) => void): Promise<{} | undefined> {
259+
Debug.assertIsDefined(host.importServicePlugin);
260+
const resolvedPath = combinePaths(initialDir, "node_modules");
261+
log(`Dynamically importing ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`);
262+
let result: ModuleImportResult;
263+
try {
264+
result = await host.importServicePlugin(resolvedPath, moduleName);
265+
}
266+
catch (e) {
267+
result = { module: undefined, error: e };
268+
}
269+
if (result.error) {
270+
const err = result.error.stack || result.error.message || JSON.stringify(result.error);
271+
(logErrors || log)(`Failed to dynamically import module '${moduleName}' from ${resolvedPath}: ${err}`);
272+
return undefined;
273+
}
274+
return result.module;
275+
}
276+
248277
/*@internal*/
249278
readonly currentDirectory: string;
250279

@@ -1574,19 +1603,19 @@ namespace ts.server {
15741603
return !!this.program && this.program.isSourceOfProjectReferenceRedirect(fileName);
15751604
}
15761605

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

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

15851614
// Search any globally-specified probe paths, then our peer node_modules
15861615
const searchPaths = [
1587-
...this.projectService.pluginProbeLocations,
1588-
// ../../.. to walk from X/node_modules/typescript/lib/tsserver.js to X/node_modules/
1589-
combinePaths(this.projectService.getExecutingFilePath(), "../../.."),
1616+
...this.projectService.pluginProbeLocations,
1617+
// ../../.. to walk from X/node_modules/typescript/lib/tsserver.js to X/node_modules/
1618+
combinePaths(this.projectService.getExecutingFilePath(), "../../.."),
15901619
];
15911620

15921621
if (this.projectService.globalPlugins) {
@@ -1606,20 +1635,51 @@ namespace ts.server {
16061635
}
16071636
}
16081637

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

1616-
const log = (message: string) => this.projectService.logger.info(message);
16171645
let errorLogs: string[] | undefined;
1646+
const log = (message: string) => this.projectService.logger.info(message);
16181647
const logError = (message: string) => {
1619-
(errorLogs || (errorLogs = [])).push(message);
1648+
(errorLogs ??= []).push(message);
16201649
};
16211650
const resolvedModule = firstDefined(searchPaths, searchPath =>
16221651
Project.resolveModule(pluginConfigEntry.name, searchPath, this.projectService.host, log, logError) as PluginModuleFactory | undefined);
1652+
return { pluginConfigEntry, pluginConfigOverrides, resolvedModule, errorLogs };
1653+
}
1654+
1655+
/**
1656+
* Performs the initial steps of enabling a plugin by finding and instantiating the module for a plugin asynchronously using dynamic `import`.
1657+
*/
1658+
/*@internal*/
1659+
async beginEnablePluginAsync(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined): Promise<BeginEnablePluginResult> {
1660+
Debug.assertIsDefined(this.projectService.host.importServicePlugin);
1661+
1662+
let errorLogs: string[] | undefined;
1663+
const log = (message: string) => this.projectService.logger.info(message);
1664+
const logError = (message: string) => {
1665+
(errorLogs ??= []).push(message);
1666+
};
1667+
1668+
let resolvedModule: PluginModuleFactory | undefined;
1669+
for (const searchPath of searchPaths) {
1670+
resolvedModule = await Project.importServicePluginAsync(pluginConfigEntry.name, searchPath, this.projectService.host, log, logError) as PluginModuleFactory | undefined;
1671+
if (resolvedModule !== undefined) {
1672+
break;
1673+
}
1674+
}
1675+
return { pluginConfigEntry, pluginConfigOverrides, resolvedModule, errorLogs };
1676+
}
1677+
1678+
/**
1679+
* Performs the remaining steps of enabling a plugin after its module has been instantiated.
1680+
*/
1681+
/*@internal*/
1682+
endEnablePlugin({ pluginConfigEntry, pluginConfigOverrides, resolvedModule, errorLogs }: BeginEnablePluginResult) {
16231683
if (resolvedModule) {
16241684
const configurationOverride = pluginConfigOverrides && pluginConfigOverrides.get(pluginConfigEntry.name);
16251685
if (configurationOverride) {
@@ -1632,11 +1692,15 @@ namespace ts.server {
16321692
this.enableProxy(resolvedModule, pluginConfigEntry);
16331693
}
16341694
else {
1635-
forEach(errorLogs, log);
1695+
forEach(errorLogs, message => this.projectService.logger.info(message));
16361696
this.projectService.logger.info(`Couldn't find ${pluginConfigEntry.name}`);
16371697
}
16381698
}
16391699

1700+
protected enablePlugin(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined): void {
1701+
this.projectService.requestEnablePlugin(this, pluginConfigEntry, searchPaths, pluginConfigOverrides);
1702+
}
1703+
16401704
private enableProxy(pluginModuleFactory: PluginModuleFactory, configEntry: PluginImport) {
16411705
try {
16421706
if (typeof pluginModuleFactory !== "function") {
@@ -2456,10 +2520,10 @@ namespace ts.server {
24562520
}
24572521

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

2462-
if (!host.require) {
2526+
if (!host.require && !host.importServicePlugin) {
24632527
this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
24642528
return;
24652529
}
@@ -2481,7 +2545,7 @@ namespace ts.server {
24812545
}
24822546
}
24832547

2484-
this.enableGlobalPlugins(options, pluginConfigOverrides);
2548+
return this.enableGlobalPlugins(options, pluginConfigOverrides);
24852549
}
24862550

24872551
/**

0 commit comments

Comments
 (0)