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:
Родитель
29dffc3079
Коммит
3fc5f968ca
16
Gulpfile.js
16
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",
|
||||
|
|
|
@ -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 {};
|
||||
|
|
Загрузка…
Ссылка в новой задаче