mirror of
https://github.com/microsoft/TypeScript.git
synced 2025-11-18 17:21:48 +00:00
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:
co-authored by
Nathan Shively-Sanders
Ron Buckton
parent
29dffc3079
commit
3fc5f968ca
@@ -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"]
|
||||
}
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user