diff --git a/Gulpfile.js b/Gulpfile.js index c9d3feff80b..a59894996e6 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -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." @@ -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", diff --git a/scripts/produceLKG.ts b/scripts/produceLKG.ts index 89199df6125..bd23b5329bb 100644 --- a/scripts/produceLKG.ts +++ b/scripts/produceLKG.ts @@ -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 diff --git a/src/dynamicImportCompat/dynamicImportCompat.ts b/src/dynamicImportCompat/dynamicImportCompat.ts new file mode 100644 index 00000000000..aa21ba70a95 --- /dev/null +++ b/src/dynamicImportCompat/dynamicImportCompat.ts @@ -0,0 +1,3 @@ +namespace ts.server { + export const dynamicImport = (id: string) => import(id); +} \ No newline at end of file diff --git a/src/dynamicImportCompat/tsconfig.json b/src/dynamicImportCompat/tsconfig.json new file mode 100644 index 00000000000..1ae16752420 --- /dev/null +++ b/src/dynamicImportCompat/tsconfig.json @@ -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", + ] +} diff --git a/src/harness/util.ts b/src/harness/util.ts index 6e0ab3cfb7d..65e0e2fbe3d 100644 --- a/src/harness/util.ts +++ b/src/harness/util.ts @@ -109,4 +109,20 @@ namespace Utils { value === undefined ? "undefined" : JSON.stringify(value); } + + export interface Deferred { + resolve: (value: T | PromiseLike) => void; + reject: (reason: unknown) => void; + promise: Promise; + } + + export function defer(): Deferred { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return { resolve, reject, promise }; + } } \ No newline at end of file diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index d40294989ee..6cca27c0335 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -804,6 +804,9 @@ namespace ts.server { private performanceEventHandler?: PerformanceEventHandler; + private pendingPluginEnablements?: ESMap[]>; + private currentPluginEnablementPromise?: Promise; + constructor(opts: ProjectServiceOptions) { this.host = opts.host; this.logger = opts.logger; @@ -4063,6 +4066,114 @@ namespace ts.server { return false; } + /*@internal*/ + requestEnablePlugin(project: Project, pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map | 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 = []); + 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[]][]) { + // 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[]) { + // 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)); diff --git a/src/server/project.ts b/src/server/project.ts index 7f7911118fc..f3c881658f8 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -102,6 +102,14 @@ namespace ts.server { export type PluginModuleFactory = (mod: { typescript: typeof ts }) => PluginModule; + /* @internal */ + export interface BeginEnablePluginResult { + pluginConfigEntry: PluginImport; + pluginConfigOverrides: Map | 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) @@ -134,6 +142,7 @@ namespace ts.server { private externalFiles: SortedReadonlyArray | undefined; private missingFilesMap: ESMap | undefined; private generatedFilesMap: GeneratedFileWatcherMap | undefined; + private plugins: PluginModuleWithName[] = []; /*@internal*/ @@ -245,6 +254,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; @@ -1574,19 +1603,19 @@ namespace ts.server { return !!this.program && this.program.isSourceOfProjectReferenceRedirect(fileName); } - protected enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map | undefined) { + protected enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map | 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) { @@ -1606,20 +1635,51 @@ namespace ts.server { } } - protected enablePlugin(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map | 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 | 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 | undefined): Promise { + 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) { @@ -1632,11 +1692,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 | undefined): void { + this.projectService.requestEnablePlugin(this, pluginConfigEntry, searchPaths, pluginConfigOverrides); + } + private enableProxy(pluginModuleFactory: PluginModuleFactory, configEntry: PluginImport) { try { if (typeof pluginModuleFactory !== "function") { @@ -2456,10 +2520,10 @@ namespace ts.server { } /*@internal*/ - enablePluginsWithOptions(options: CompilerOptions, pluginConfigOverrides: ESMap | undefined) { + enablePluginsWithOptions(options: CompilerOptions, pluginConfigOverrides: ESMap | 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; } @@ -2481,7 +2545,7 @@ namespace ts.server { } } - this.enableGlobalPlugins(options, pluginConfigOverrides); + return this.enableGlobalPlugins(options, pluginConfigOverrides); } /** diff --git a/src/server/session.ts b/src/server/session.ts index 0905c46ecca..a598178514b 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -696,7 +696,6 @@ namespace ts.server { CommandNames.OrganizeImportsFull, CommandNames.GetEditsForFileRename, CommandNames.GetEditsForFileRenameFull, - CommandNames.ConfigurePlugin, CommandNames.PrepareCallHierarchy, CommandNames.ProvideCallHierarchyIncomingCalls, CommandNames.ProvideCallHierarchyOutgoingCalls, @@ -3344,7 +3343,9 @@ namespace ts.server { public executeCommand(request: protocol.Request): HandlerResponse { const handler = this.handlers.get(request.command); if (handler) { - return this.executeWithRequestId(request.seq, () => handler(request)); + const response = this.executeWithRequestId(request.seq, () => handler(request)); + this.projectService.enableRequestedPlugins(); + return response; } else { this.logger.msg(`Unrecognized JSON command:${stringifyIndented(request)}`, Msg.Err); diff --git a/src/server/types.ts b/src/server/types.ts index 671e854440d..b19879a9492 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -5,7 +5,11 @@ declare namespace ts.server { data: any; } - export type RequireResult = { module: {}, error: undefined } | { module: undefined, error: { stack?: string, message?: string } }; + export type ModuleImportResult = { module: {}, error: undefined } | { module: undefined, error: { stack?: string, message?: string } }; + + /** @deprecated Use {@link ModuleImportResult} instead. */ + export type RequireResult = ModuleImportResult; + export interface ServerHost extends System { watchFile(path: string, callback: FileWatcherCallback, pollingInterval?: number, options?: WatchOptions): FileWatcher; watchDirectory(path: string, callback: DirectoryWatcherCallback, recursive?: boolean, options?: WatchOptions): FileWatcher; @@ -15,6 +19,7 @@ declare namespace ts.server { clearImmediate(timeoutId: any): void; gc?(): void; trace?(s: string): void; - require?(initialPath: string, moduleName: string): RequireResult; + require?(initialPath: string, moduleName: string): ModuleImportResult; + importServicePlugin?(root: string, moduleName: string): Promise; } } diff --git a/src/testRunner/unittests/tsserver/webServer.ts b/src/testRunner/unittests/tsserver/webServer.ts index 1891596ffb0..d9f09eb4d4b 100644 --- a/src/testRunner/unittests/tsserver/webServer.ts +++ b/src/testRunner/unittests/tsserver/webServer.ts @@ -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, 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 => { + 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 => { + 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 => { + 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 => { + // 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 => { + // 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"] + } + }]); + }); + }); }); } diff --git a/src/tsconfig.json b/src/tsconfig.json index 8a0f4dbce0e..ca929ee198c 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -9,6 +9,7 @@ { "path": "./watchGuard" }, { "path": "./debug" }, { "path": "./cancellationToken" }, + { "path": "./dynamicImportCompat" }, { "path": "./testRunner" } ] } \ No newline at end of file diff --git a/src/tsserver/nodeServer.ts b/src/tsserver/nodeServer.ts index b98131ec965..e17a7dd37e4 100644 --- a/src/tsserver/nodeServer.ts +++ b/src/tsserver/nodeServer.ts @@ -273,7 +273,7 @@ namespace ts.server { sys.gc = () => global.gc?.(); } - sys.require = (initialDir: string, moduleName: string): RequireResult => { + sys.require = (initialDir: string, moduleName: string): ModuleImportResult => { try { return { module: require(resolveJSModule(moduleName, initialDir, sys)), error: undefined }; } diff --git a/src/webServer/webServer.ts b/src/webServer/webServer.ts index 31f0a47915b..0cad87dd97b 100644 --- a/src/webServer/webServer.ts +++ b/src/webServer/webServer.ts @@ -1,4 +1,7 @@ /*@internal*/ +/// +/// + namespace ts.server { export interface HostWithWriteMessage { writeMessage(s: any): void; @@ -109,11 +112,34 @@ namespace ts.server { } } + export declare const dynamicImport: ((id: string) => Promise) | undefined; + + // Attempt to load `dynamicImport` + if (typeof importScripts === "function") { + try { + // NOTE: importScripts is synchronous + importScripts("dynamicImportCompat.js"); + } + catch { + // ignored + } + } + export function createWebSystem(host: WebHost, args: string[], getExecutingFilePath: () => string): ServerHost { const returnEmptyString = () => ""; const getExecutingDirectoryPath = memoize(() => memoize(() => ensureTrailingDirectorySeparator(getDirectoryPath(getExecutingFilePath())))); // Later we could map ^memfs:/ to do something special if we want to enable more functionality like module resolution or something like that const getWebPath = (path: string) => startsWith(path, directorySeparator) ? path.replace(directorySeparator, getExecutingDirectoryPath()) : undefined; + + const dynamicImport = async (id: string): Promise => { + // Use syntactic dynamic import first, if available + if (server.dynamicImport) { + return server.dynamicImport(id); + } + + throw new Error("Dynamic import not implemented"); + }; + return { args, newLine: "\r\n", // This can be configured by clients @@ -136,7 +162,32 @@ namespace ts.server { clearImmediate: handle => clearTimeout(handle), /* eslint-enable no-restricted-globals */ - require: () => ({ module: undefined, error: new Error("Not implemented") }), + importServicePlugin: async (initialDir: string, moduleName: string): Promise => { + const packageRoot = combinePaths(initialDir, moduleName); + + let packageJson: any | undefined; + try { + const packageJsonResponse = await fetch(combinePaths(packageRoot, "package.json")); + packageJson = await packageJsonResponse.json(); + } + catch (e) { + return { module: undefined, error: new Error("Could not load plugin. Could not load 'package.json'.") }; + } + + const browser = packageJson.browser; + if (!browser) { + return { module: undefined, error: new Error("Could not load plugin. No 'browser' field found in package.json.") }; + } + + const scriptPath = combinePaths(packageRoot, browser); + try { + const { default: module } = await dynamicImport(scriptPath); + return { module, error: undefined }; + } + catch (e) { + return { module: undefined, error: e }; + } + }, exit: notImplemented, // Debugging related diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 9401f57e76e..b2986879dac 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -6980,7 +6980,7 @@ declare namespace ts.server { compressionKind: string; data: any; } - type RequireResult = { + type ModuleImportResult = { module: {}; error: undefined; } | { @@ -6990,6 +6990,8 @@ declare namespace ts.server { message?: string; }; }; + /** @deprecated Use {@link ModuleImportResult} instead. */ + type RequireResult = ModuleImportResult; interface ServerHost extends System { watchFile(path: string, callback: FileWatcherCallback, pollingInterval?: number, options?: WatchOptions): FileWatcher; watchDirectory(path: string, callback: DirectoryWatcherCallback, recursive?: boolean, options?: WatchOptions): FileWatcher; @@ -6999,7 +7001,8 @@ declare namespace ts.server { clearImmediate(timeoutId: any): void; gc?(): void; trace?(s: string): void; - require?(initialPath: string, moduleName: string): RequireResult; + require?(initialPath: string, moduleName: string): ModuleImportResult; + importServicePlugin?(root: string, moduleName: string): Promise; } } declare namespace ts.server { @@ -10449,6 +10452,8 @@ declare namespace ts.server { /** Tracks projects that we have already sent telemetry for. */ private readonly seenProjects; private performanceEventHandler?; + private pendingPluginEnablements?; + private currentPluginEnablementPromise?; constructor(opts: ProjectServiceOptions); toPath(fileName: string): Path; private loadTypesMap; @@ -10608,6 +10613,9 @@ declare namespace ts.server { applySafeList(proj: protocol.ExternalProject): NormalizedPath[]; openExternalProject(proj: protocol.ExternalProject): void; hasDeferredExtension(): boolean; + private enableRequestedPluginsAsync; + private enableRequestedPluginsWorker; + private enableRequestedPluginsForProjectAsync; configurePlugin(args: protocol.ConfigurePluginRequestArguments): void; } export {};