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 <293473+sandersn@users.noreply.github.com>

* Partial PR feedback

* Rename tsserverWeb to dynamicImportCompat

* Additional PR feedback

Co-authored-by: Ron Buckton <ron.buckton@microsoft.com>
Co-authored-by: Nathan Shively-Sanders <293473+sandersn@users.noreply.github.com>
This commit is contained in:
Matt Bierner
2022-06-14 12:35:53 -07:00
committed by GitHub
co-authored by Nathan Shively-Sanders Ron Buckton
parent 29dffc3079
commit 3fc5f968ca
14 changed files with 519 additions and 30 deletions
+204 -2
View File
@@ -1,3 +1,4 @@
/* eslint-disable boolean-trivia */
namespace ts.projectSystem {
describe("unittests:: tsserver:: webServer", () => {
class TestWorkerSession extends server.WorkerSession {
@@ -27,7 +28,8 @@ namespace ts.projectSystem {
return this.projectService;
}
}
function setup(logLevel: server.LogLevel | undefined) {
function setup(logLevel: server.LogLevel | undefined, options?: Partial<server.StartSessionOptions>, importServicePlugin?: server.ServerHost["importServicePlugin"]) {
const host = createServerHost([libFile], { windowsStyleRoot: "c:/" });
const messages: any[] = [];
const webHost: server.WebHost = {
@@ -36,8 +38,9 @@ namespace ts.projectSystem {
writeMessage: s => messages.push(s),
};
const webSys = server.createWebSystem(webHost, emptyArray, () => host.getExecutingFilePath());
webSys.importServicePlugin = importServicePlugin;
const logger = logLevel !== undefined ? new server.MainProcessLogger(logLevel, webHost) : nullLogger();
const session = new TestWorkerSession(webSys, webHost, { serverMode: LanguageServiceMode.PartialSemantic }, logger);
const session = new TestWorkerSession(webSys, webHost, { serverMode: LanguageServiceMode.PartialSemantic, ...options }, logger);
return { getMessages: () => messages, clearMessages: () => messages.length = 0, session };
}
@@ -153,5 +156,204 @@ namespace ts.projectSystem {
verify(/*logLevel*/ undefined);
});
});
describe("async loaded plugins", () => {
it("plugins are not loaded immediately", async () => {
let pluginModuleInstantiated = false;
let pluginInvoked = false;
const importServicePlugin = async (_root: string, _moduleName: string): Promise<server.ModuleImportResult> => {
await Promise.resolve(); // simulate at least a single turn delay
pluginModuleInstantiated = true;
return {
module: (() => {
pluginInvoked = true;
return { create: info => info.languageService };
}) as server.PluginModuleFactory,
error: undefined
};
};
const { session } = setup(/*logLevel*/ undefined, { globalPlugins: ["plugin-a"] }, importServicePlugin);
const projectService = session.getProjectService();
session.executeCommand({ seq: 1, type: "request", command: protocol.CommandTypes.Open, arguments: { file: "^memfs:/foo.ts", content: "" } });
// This should be false because `executeCommand` should have already triggered
// plugin enablement asynchronously and there are no plugin enablements currently
// being processed.
expect(projectService.hasNewPluginEnablementRequests()).eq(false);
// Should be true because async imports have already been triggered in the background
expect(projectService.hasPendingPluginEnablements()).eq(true);
// Should be false because resolution of async imports happens in a later turn.
expect(pluginModuleInstantiated).eq(false);
await projectService.waitForPendingPlugins();
// at this point all plugin modules should have been instantiated and all plugins
// should have been invoked
expect(pluginModuleInstantiated).eq(true);
expect(pluginInvoked).eq(true);
});
it("plugins evaluation in correct order even if imports resolve out of order", async () => {
const pluginADeferred = Utils.defer();
const pluginBDeferred = Utils.defer();
const log: string[] = [];
const importServicePlugin = async (_root: string, moduleName: string): Promise<server.ModuleImportResult> => {
log.push(`request import ${moduleName}`);
const promise = moduleName === "plugin-a" ? pluginADeferred.promise : pluginBDeferred.promise;
await promise;
log.push(`fulfill import ${moduleName}`);
return {
module: (() => {
log.push(`invoke plugin ${moduleName}`);
return { create: info => info.languageService };
}) as server.PluginModuleFactory,
error: undefined
};
};
const { session } = setup(/*logLevel*/ undefined, { globalPlugins: ["plugin-a", "plugin-b"] }, importServicePlugin);
const projectService = session.getProjectService();
session.executeCommand({ seq: 1, type: "request", command: protocol.CommandTypes.Open, arguments: { file: "^memfs:/foo.ts", content: "" } });
// wait a turn
await Promise.resolve();
// resolve imports out of order
pluginBDeferred.resolve();
pluginADeferred.resolve();
// wait for load to complete
await projectService.waitForPendingPlugins();
expect(log).to.deep.equal([
"request import plugin-a",
"request import plugin-b",
"fulfill import plugin-b",
"fulfill import plugin-a",
"invoke plugin plugin-a",
"invoke plugin plugin-b",
]);
});
it("sends projectsUpdatedInBackground event", async () => {
const importServicePlugin = async (_root: string, _moduleName: string): Promise<server.ModuleImportResult> => {
await Promise.resolve(); // simulate at least a single turn delay
return {
module: (() => ({ create: info => info.languageService })) as server.PluginModuleFactory,
error: undefined
};
};
const { session, getMessages } = setup(/*logLevel*/ undefined, { globalPlugins: ["plugin-a"] }, importServicePlugin);
const projectService = session.getProjectService();
session.executeCommand({ seq: 1, type: "request", command: protocol.CommandTypes.Open, arguments: { file: "^memfs:/foo.ts", content: "" } });
await projectService.waitForPendingPlugins();
expect(getMessages()).to.deep.equal([{
seq: 0,
type: "event",
event: "projectsUpdatedInBackground",
body: {
openFiles: ["^memfs:/foo.ts"]
}
}]);
});
it("adds external files", async () => {
const pluginAShouldLoad = Utils.defer();
const pluginAExternalFilesRequested = Utils.defer();
const importServicePlugin = async (_root: string, _moduleName: string): Promise<server.ModuleImportResult> => {
// wait until the initial external files are requested from the project service.
await pluginAShouldLoad.promise;
return {
module: (() => ({
create: info => info.languageService,
getExternalFiles: () => {
// signal that external files have been requested by the project service.
pluginAExternalFilesRequested.resolve();
return ["external.txt"];
}
})) as server.PluginModuleFactory,
error: undefined
};
};
const { session } = setup(/*logLevel*/ undefined, { globalPlugins: ["plugin-a"] }, importServicePlugin);
const projectService = session.getProjectService();
session.executeCommand({ seq: 1, type: "request", command: protocol.CommandTypes.Open, arguments: { file: "^memfs:/foo.ts", content: "" } });
const project = projectService.inferredProjects[0];
// get the external files we know about before plugins are loaded
const initialExternalFiles = project.getExternalFiles();
// we've ready the initial set of external files, allow the plugin to continue loading.
pluginAShouldLoad.resolve();
// wait for plugins
await projectService.waitForPendingPlugins();
// wait for the plugin's external files to be requested
await pluginAExternalFilesRequested.promise;
// get the external files we know aobut after plugins are loaded
const pluginExternalFiles = project.getExternalFiles();
expect(initialExternalFiles).to.deep.equal([]);
expect(pluginExternalFiles).to.deep.equal(["external.txt"]);
});
it("project is closed before plugins are loaded", async () => {
const pluginALoaded = Utils.defer();
const projectClosed = Utils.defer();
const importServicePlugin = async (_root: string, _moduleName: string): Promise<server.ModuleImportResult> => {
// mark that the plugin has started loading
pluginALoaded.resolve();
// wait until after a project close has been requested to continue
await projectClosed.promise;
return {
module: (() => ({ create: info => info.languageService })) as server.PluginModuleFactory,
error: undefined
};
};
const { session, getMessages } = setup(/*logLevel*/ undefined, { globalPlugins: ["plugin-a"] }, importServicePlugin);
const projectService = session.getProjectService();
session.executeCommand({ seq: 1, type: "request", command: protocol.CommandTypes.Open, arguments: { file: "^memfs:/foo.ts", content: "" } });
// wait for the plugin to start loading
await pluginALoaded.promise;
// close the project
session.executeCommand({ seq: 2, type: "request", command: protocol.CommandTypes.Close, arguments: { file: "^memfs:/foo.ts" } });
// continue loading the plugin
projectClosed.resolve();
await projectService.waitForPendingPlugins();
// the project was closed before plugins were ready. no project update should have been requested
expect(getMessages()).not.to.deep.equal([{
seq: 0,
type: "event",
event: "projectsUpdatedInBackground",
body: {
openFiles: ["^memfs:/foo.ts"]
}
}]);
});
});
});
}