-
Notifications
You must be signed in to change notification settings - Fork 12.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Language service proxy #12231
Language service proxy #12231
Conversation
cc @chuckjaz |
@@ -142,6 +154,35 @@ namespace ts.server { | |||
return this.cachedUnresolvedImportsPerFile; | |||
} | |||
|
|||
public static resolveModule(moduleName: string, initialDir: string, host: ServerHost, log: (message: string) => void): {} { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not just reuse the module resolver the TS compiler uses elsewhere? I understand that the module resolver is a lot more complicated thanks to its exposure the the compiler options, but it should be configurable to load just JS, right? At least, it'd probably be better not to duplicate the folder traversal/lookup logic in two places?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered this but didn't think it was worthwhile given that we'd have to mock out a CompilerOptions
guaranteed to behave the same as NodeJS's resolution (which still differs from our own given how we crack open package.jsons and other stuff). I'm actually going to switch this to just use require
directly anyway now that I've learned how to set up a different initial search path.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@RyanCavanaugh Shouldn't require
at least be abstracted behind a call to sys
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@weswigham I don't really see a point - is anyone plausibly going to write a plugin that works under a host that's not node?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what about substituting require
in unit tests?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And I'm sure someone like @basart would like to support angular and other plugins in his cloud IDE.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🌹
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't really see a point - is anyone plausibly going to write a plugin that works under a host that's not node?
@RyanCavanaugh Isn't there an intent for this to work in Visual Studio? Our Chakra host would need to implement its own require
logic in that case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@DanielRosenwasser VS17 uses tsserver + node so it won't be a problem
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's already been refactored into sys
as suggested.
src/server/project.ts
Outdated
return; | ||
} | ||
|
||
if (typeof require === "undefined") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than using the global require
directly, shouldn't this be abstracted as a host detail under sys
?
src/server/project.ts
Outdated
const items: string[] = []; | ||
for (const plugin of this.plugins) { | ||
if (plugin.getExternalFiles) { | ||
items.push(...plugin.getExternalFiles(this)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I'm not mistaken, this calls plugin code which can throw - any errors should probably be handled and logged?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup. we need to wrapp this in a try..catch
block to make sure the project is not in an invalid state.
src/harness/fourslash.ts
Outdated
const proxy = Object.create(null); | ||
const langSvc: any = info.languageService; | ||
for (const k of Object.keys(langSvc)) { | ||
proxy[k] = function () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The LS codebase (and the API's consumers) are not resilient to exceptions being thrown from these API methods, however consumers of the plugin API are not bound by any contract forbidding exceptions. Is there some way we can force a safer construction of these proxy methods whereby execution errors caused by improper plugins are appropriately logged and recorded, rather than surfaced as ICEs?
@@ -90,6 +90,18 @@ namespace ts.server { | |||
} | |||
} | |||
|
|||
export interface PluginCreateInfo { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if this is still a priority, but should plugins be given the opportunity to be well-behaved with respect to a cancellation token?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1, plugins should be monitoring cancellation requests similar to normal LS methods
1c52714
to
d0f3dc7
Compare
@mhegazy ping |
Angular-side PR is up at angular/angular#13716 |
Ping anyone... |
Please? |
src/compiler/sys.ts
Outdated
|
||
// Note: 'require' needs the path to be all forward slashes and absolute | ||
initialDir = normalizeSlashes(nodeSystem.resolvePath(initialDir)); | ||
mod.paths.unshift(initialDir); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd be wary of this. 'module.paths' isn't documented at all on the Node.js site (https://nodejs.org/dist/latest-v6.x/docs/api/modules.html). I'd assume this is an internal property which could change or disappear at any time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely aware (see earlier comment in this function for a link to an SO question about this). I considered this to be preferable to re-implementing their module resolution algorithm since we don't necessarily know how it works in all cases. Thoughts?
@@ -3358,7 +3364,7 @@ namespace ts { | |||
/* @internal */ | |||
export interface CommandLineOptionOfListType extends CommandLineOptionBase { | |||
type: "list"; | |||
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType; | |||
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType | TsConfigOnlyOption; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This reads weird and needs a comment if deliberate. By definition, I would assume a "TsConfigOnlyOption" can't be provided as a CommandLineOption?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's how everything here is named. Note that CommandLineOptionBase
has an isTSConfigOnly
property, which you would think would have to be false by definition
src/server/project.ts
Outdated
} | ||
|
||
for (const pluginConfigEntry of options.plugins) { | ||
const searchPath = combinePaths(this.configFileName, ".."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This reads like it's going up a folder to the parent of the folder with the config file, but I assume the intent is just to remove the tsconfig.json and leave the folder. Looking at the implementation of combinePaths, I believe it's just going to result in these being concatenated with a directory separator (e.g. /src/project/tsconfig.json/..
), which may work for 'resolveModule', but seems a little bizarre.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will change to path.dirname
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getDirectoryPath
rather
src/server/project.ts
Outdated
} | ||
catch (e) { | ||
this.projectService.logger.info(`Plugin activation failed: ${e}`); | ||
this.projectService.logger.info(`Plugin object was: ${pluginModule}`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this just going to log as [object]
? Did you mean to JSON.stringify it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it's an object, yes. In this case I needed to find out that it wasn't null, wasn't undefined, wasn't a class, wasn't a primitive, and wasn't a function (the angular module was returning a factory function when I expected an object in this case). I would be worried calling JSON.stringify
here as it could trigger a secondary exception during a property getter
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You might want to browse the spec (http://es5.github.io/#x15.12.3) and run some testing to verify, but JSON.stringify never throws (even for undefined, null, NaN, etc..) and usually outputs what you would want.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does throw.
If stack contains value then throw a TypeError exception because the structure is cyclical.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, I should have been clearer. I meant based on the property type (i.e. addressing the comment I needed to find out that it wasn't null, wasn't undefined, wasn't a class, wasn't a primitive, and wasn't a function
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated to do some better logging of Objects. I believe Object.keys
cannot throw on an object
input
//// | ||
|
||
goTo.marker(); | ||
verify.quickInfoIs('Proxied x: number[]Check'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tests seem a little light for a new feature. We need to test what happens when the plugin fails to load (either throws on initialization or can't be found), if the plugin config is misconfigured, if the proxied API returns invalid results, etc... We need to ensure we're either resilient or give an actionable error to help identify the cause, else we'll be troubleshooting a lot of issues that aren't ours 😟
Hate to be bumping, but is there anything that still prevents this from being ready? |
How does plugins work under watch mode? For example if I create a plugin to require resx file for resources, I would like to automatically watch all languages besides the imported file and generate a typescript equivalent code. |
public static resolveModule(moduleName: string, initialDir: string, host: ServerHost, log: (message: string) => void): {} { | ||
const resolvedPath = normalizeSlashes(host.resolvePath(combinePaths(initialDir, "node_modules"))); | ||
log(`Loading ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`); | ||
const result = host.require(resolvedPath, moduleName); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i would wrap this in a try/catch block and log the exception
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
or just do this in enableProxy
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
require
can't throw. It returns a module or an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you please put it as a comment to ServerHost.require
since we don't have any other ways to declare/enforce this contract?
src/server/project.ts
Outdated
languageServiceHost: this.lsHost, | ||
serverHost: this.projectService.host | ||
}; | ||
if (pluginModule.create === undefined && typeof pluginModule === "function") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there a reason why we have two ways to define a module? why would not create
take ts
as well? how would it get ts
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we define the shape of a plugin in an interface as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is how angular's module loading scheme works. We could force everyone to follow this pattern, or allow both ways. Thoughts?
src/server/project.ts
Outdated
getExternalFiles(): string[] { | ||
const items: string[] = []; | ||
for (const plugin of this.plugins) { | ||
if (plugin.getExternalFiles) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
typeof plugin.getExternalFiles !== "function"
src/server/project.ts
Outdated
const items: string[] = []; | ||
for (const plugin of this.plugins) { | ||
if (plugin.getExternalFiles) { | ||
items.push(...plugin.getExternalFiles(this)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup. we need to wrapp this in a try..catch
block to make sure the project is not in an invalid state.
scripts/buildProtocol.ts
Outdated
@@ -167,7 +167,7 @@ function generateProtocolFile(protocolTs: string, typeScriptServicesDts: string) | |||
const sanityCheckProgram = getProgramWithProtocolText(protocolDts, /*includeTypeScriptServices*/ false); | |||
const diagnostics = [...sanityCheckProgram.getSyntacticDiagnostics(), ...sanityCheckProgram.getSemanticDiagnostics(), ...sanityCheckProgram.getGlobalDiagnostics()]; | |||
if (diagnostics.length) { | |||
const flattenedDiagnostics = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n")).join("\n"); | |||
const flattenedDiagnostics = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n") + ' at ' + d.file.fileName + ' line ' + d.start).join("\n"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
`${ts.flattenDiagnosticMessageText(d.messageText, "\n")} at ${d.file.fileName} line ${d.start}
src/compiler/sys.ts
Outdated
@@ -569,7 +573,31 @@ namespace ts { | |||
} | |||
}, | |||
setTimeout, | |||
clearTimeout | |||
clearTimeout, | |||
require(initialDir: string, moduleName: string) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we move require
from sys
to ServerHost
?
- it looks like currently only tsserver will use it
- if we do it in tsserver we can use our module resolution implementation and then do
require
with resolved path without relying on undocumented properties
src/compiler/sys.ts
Outdated
@@ -45,6 +45,8 @@ namespace ts { | |||
clearTimeout?(timeoutId: any): void; | |||
} | |||
|
|||
export type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would move this to types.ts or server.ts
@mhegazy any other comments? |
Looks good to me. @vladima any final comments? |
src/compiler/moduleNameResolver.ts
Outdated
@@ -683,14 +683,14 @@ namespace ts { | |||
} | |||
} | |||
|
|||
export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache): ResolvedModuleWithFailedLookupLocations { | |||
export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, jsOnly = false): ResolvedModuleWithFailedLookupLocations { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can we instead of using default parameter split this function into two:
export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost): ResolvedModuleWithFailedLookupLocations {
return nodeModuleNameResolverWorker(moduleName, containingFile, compilerOptions, host, /*cache*/ undefined, /*jsOnly*/ false);
}
/* @internal */
export function nodeModuleNameResolverWorker(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache: ModuleResolutionCache, jsOnly): ResolvedModuleWithFailedLookupLocations {
...
}
and make internal callers use nodeModuleNameResolverWorker
instead of nodeModuleNameResolver
Reason: I think it is preferable to keep public interface of the compiler clean and not pollute it with internal intricacies
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
src/compiler/sys.ts
Outdated
@@ -582,7 +582,7 @@ namespace ts { | |||
} | |||
}, | |||
setTimeout, | |||
clearTimeout | |||
clearTimeout, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: extra comma
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
src/server/project.ts
Outdated
|
||
public languageServiceEnabled = true; | ||
|
||
protected lsHost: LSHost; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you also make it readonly
? AFAIR nobody should change it once LSHost
is created
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
src/server/project.ts
Outdated
serverHost: this.projectService.host | ||
}; | ||
|
||
if (typeof pluginModuleFactory !== "function") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
move the check before making info
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, with a nit about making plugin aware of cancellation token
188fb76
to
aec3109
Compare
🎉 |
Is this what's described in #11976 ? I can see that @angelozerr says in #13436 (comment) But #13436 doesn't actually include any answer with such an example. Was the usage described elsewhere? Offline? Design notes mention this PR only in passing too. It looks like a huge layer of functionality implemented on the sly! 😎 ㊙️ |
We still need to add documentation for this feature. This is scheduled for TS 2.3 at the moment. |
With these changes in place, can we already achieve the holy grail of extensions and introduce arbitrary types in the system or is that still some way off? There are two use-cases that I'm thinking of: const foo = Relay.QL`fragment on Foo { name, bar { count } }`
require.ensure("lib/bar", (error, bar) => bar.baz()) In the first case, the rest of the code should be type checked as if The type checker in |
Implements basic language service proxy plugins