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 коммит произвёл GitHub
Родитель 29dffc3079
Коммит 3fc5f968ca
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
14 изменённых файлов: 519 добавлений и 30 удалений

Просмотреть файл

@ -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",

Просмотреть файл

@ -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

Просмотреть файл

@ -0,0 +1,3 @@
namespace ts.server {
export const dynamicImport = (id: string) => import(id);
}

Просмотреть файл

@ -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",
]
}

Просмотреть файл

@ -109,4 +109,20 @@ namespace Utils {
value === undefined ? "undefined" :
JSON.stringify(value);
}
export interface Deferred<T> {
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason: unknown) => void;
promise: Promise<T>;
}
export function defer<T = void>(): Deferred<T> {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return { resolve, reject, promise };
}
}

Просмотреть файл

@ -804,6 +804,9 @@ namespace ts.server {
private performanceEventHandler?: PerformanceEventHandler;
private pendingPluginEnablements?: ESMap<Project, Promise<BeginEnablePluginResult>[]>;
private currentPluginEnablementPromise?: Promise<void>;
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<any> | 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<BeginEnablePluginResult>[]][]) {
// 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<BeginEnablePluginResult>[]) {
// 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));

Просмотреть файл

@ -102,6 +102,14 @@ namespace ts.server {
export type PluginModuleFactory = (mod: { typescript: typeof ts }) => PluginModule;
/* @internal */
export interface BeginEnablePluginResult {
pluginConfigEntry: PluginImport;
pluginConfigOverrides: Map<any> | 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<string> | undefined;
private missingFilesMap: ESMap<Path, FileWatcher> | 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<any> | undefined) {
protected enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map<any> | 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<any> | 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<any> | 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<any> | undefined): Promise<BeginEnablePluginResult> {
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<any> | 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<string, any> | undefined) {
enablePluginsWithOptions(options: CompilerOptions, pluginConfigOverrides: ESMap<string, any> | 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);
}
/**

Просмотреть файл

@ -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);

Просмотреть файл

@ -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<ModuleImportResult>;
}
}

Просмотреть файл

@ -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"]
}
}]);
});
});
});
}

Просмотреть файл

@ -9,6 +9,7 @@
{ "path": "./watchGuard" },
{ "path": "./debug" },
{ "path": "./cancellationToken" },
{ "path": "./dynamicImportCompat" },
{ "path": "./testRunner" }
]
}

Просмотреть файл

@ -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 };
}

Просмотреть файл

@ -1,4 +1,7 @@
/*@internal*/
/// <reference lib="dom" />
/// <reference lib="webworker.importscripts" />
namespace ts.server {
export interface HostWithWriteMessage {
writeMessage(s: any): void;
@ -109,11 +112,34 @@ namespace ts.server {
}
}
export declare const dynamicImport: ((id: string) => Promise<any>) | 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<any> => {
// 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<ModuleImportResult> => {
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

Просмотреть файл

@ -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<ModuleImportResult>;
}
}
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 {};