Skip to content

Latest commit

 

History

History
55 lines (40 loc) · 13.5 KB

tsserver-host.md

File metadata and controls

55 lines (40 loc) · 13.5 KB

tsserver host knowledge

Verified facts about the environment this plugin runs in. Updated 2026-09-28; each item names the version it was checked against. Re-verify before relying on an item older than a few months.

How tsserver loads a plugin

  • The Node tsserver host loads plugins with a synchronous require() (TypeScript 5.9 and 6.0: src/compiler/sys.ts nodeSystem.require, src/server/project.ts importServicePluginSync). The async importPlugin path exists only for hosts that implement it; the Node CLI host does not.
  • Resolution uses classic Node10 rules from the host's own directory or --pluginProbeLocations: package.json main is what tsserver finds, not the exports map (NodeResolutionFeatures.None, no Exports feature bit, in tryResolveJSModuleWorker). The plugin probe is relative to the realpath of the typescript package tsserver loaded, not the project directory, so a symlinked typescript install (a version-manager shim, a monorepo hoist) sends the probe to the symlink target's tree; a test harness assembling an isolated project must copy typescript in, not symlink it.
  • TypeScript 4.4 and older do not resolve a .mjs main at all ("Could not resolve JS module" in the tsserver log). The same is true of a .cjs main: measured against a real tsserver on TypeScript 3.9, 4.0, and 4.4 with main: "./lib/index.cjs", the log shows the same "Could not resolve JS module" failure, trying lib/index.cjs.js, lib/index.cjs/index.js, and similar mangled suffixes rather than the literal file; TypeScript 4.9 and newer resolve .cjs correctly. This package's root main is lib/index.js (a real CommonJS file, disambiguated from the package root's "type": "module" by a nested lib/package.json), which every supported TypeScript version's Node10 resolution finds.
  • typesVersions (package.json) applies to this same classic-resolution code path, not only to a consumer's own type lookup: tryResolveJSModuleWorker shares the "resolve main or a subpath" logic with the types resolver, so a typesVersions entry keyed on the same relative path as main (here, lib/index or lib/index.js) redirects the plugin loader's own runtime require() to the declaration file instead of the implementation, which then throws SyntaxError: Cannot use import statement outside a module trying to execute it. Measured on TypeScript 5.0 by adding and removing such an entry against a real tsserver. A typesVersions entry for any OTHER subpath (one main itself never resolves to, such as ./api, ./lib/api, or ./lib/api.js here) does not collide, since the plugin loader never requests those paths.
  • The configuration a plugin receives at creation (PluginCreateInfo.config) is the whole tsconfig plugins entry, name included. A plugin an editor loads globally (--globalPlugins, as VS Code does for an extension's typescriptServerPlugins) receives { name, global: true }, and a configurePlugin payload that arrives before the project loads replaces the entry, with name set on it. A later configurePlugin payload reaches onConfigurationChanged as sent. Checked in TypeScript 5.0.4 (lib/tsserver.js) and 6.0.3 (lib/typescript.js): enableGlobalPlugins, endEnablePlugin, enableProxy, onPluginConfigurationChanged.
  • A plugin that fails to load or activate never surfaces to the user: the only trace is the tsserver log. An empty completion response from tsserver also reports success: false, so harnesses must read the log to distinguish "plugin skipped" from "no results".
  • Helix plugin setup (Helix 25.07.1 and typescript-language-server 6.0.1, checked 2026-09-28): Helix merges languages.toml through three table levels, so a user-provided language-server.typescript-language-server.config replaces that server's built-in config table. A plugin example must repeat the built-in hostInfo and TypeScript/JavaScript inlay-hint settings to preserve them. typescript-language-server passes each initialization plugin's location directly to tsserver through --pluginProbeLocations; its accepted location forms resolve under the classic Node10 rules above.

TypeScript versions (2026-09-26)

  • typescript@latest on npm is 7.0.2, the native compiler. The package ships no tsserver and has no plugin API; VS Code's TypeScript 7 mode does not load typescriptServerPlugins. Plugins run only on TypeScript 5.x and 6.x hosts; an older host can still load the plugin module itself without erroring, since this plugin's own version gate (below) returns the host's language service untouched rather than activating.
  • VS Code stable bundles TypeScript 6 (npm:@typescript/typescript6).
  • Floor for this plugin: TypeScript 5.0 (major >= 5, isSupportedTypeScriptVersion, src/tsserver/tsserver-plugin.ts). 1.0.1 gates on major >= 3, but typescript-template-language-service-decorator 2.3.2 (the last release) binds languageService.getSupportedCodeFixes whenever the template service passed to it implements the same method, regardless of host version; that instance method exists on ts.LanguageService only from TypeScript 5.0 (confirmed via ts.createLanguageService(...).getSupportedCodeFixes returning undefined on 3.9, 4.0, 4.4, and 4.9, and a function from 5.0), so binding it on an older host throws TypeError: Cannot read properties of undefined (reading 'bind') before any feature activates. Reproduced against a real tsserver on TypeScript 3.9, 4.0, 4.4, and 4.9 with the published decorator's own logged message: "Plugin activation failed: TypeError: Cannot read properties of undefined (reading 'bind')". 1.0.1 crashes identically on these versions today, since a fresh install resolves the same final decorator release. This plugin's own gate never constructs the decorator below TypeScript 5.0, logging "Unsupported TypeScript version ... TypeScript 5.0 or newer required" and returning the host's own language service untouched instead of crashing there.
  • Line terminators: ts.computeLineStarts starts a new line after \r\n, a lone \n, a lone \r, U+2028, and U+2029, and not after U+0085. Checked on TypeScript 4.9.5, 5.0.4, 5.9.3, and 6.0.3 (computeLineStarts("a\r\nb\nc\rd\u2028e\u2029f\u0085g") returns [0,3,5,7,9,11] on each).
  • A declaration file using a string export name (export { x as 'module.exports' }) fails to parse (TS1003) on TypeScript 5.0 through 5.5; 5.6 accepts it. This package no longer emits that syntax anywhere (the root entry's declaration uses export =, parseable since TypeScript's earliest CommonJS-module support); kept here as the reason a future root-entry declaration should avoid the string-export-name form.

Node runtime for the tsserver plugin and the ./api subpath

  • The package root (the tsserver plugin factory) is plain CommonJS: require() works on every Node version this plugin's dependencies support, with no require(esm) interop involved. tsdown's target for every output (root CJS, ./api CJS, ./api ESM) is node14, so syntax this package's own source uses (||=, optional chaining, and similar) is transpiled to a Node 14-safe form; tsdown does not polyfill runtime APIs, and this package's source uses none introduced after Node 14 (checked: no Array.prototype.at, Object.hasOwn, structuredClone, or String.prototype.replaceAll). Measured with the packed tarball's root, both require() and import(): Node 14.21.3, 16.20.2, 18.20.5, 20.18.1, 22.12.0, and 24.11.0 all load it and return the factory (import() gets it as the default export, since Node's ESM loader synthesizes a default from a CommonJS module's module.exports).
  • Node 12.22.12 fails for both this package and 1.0.1, from the same cause neither controls: this package's runtime dependencies (vscode-css-languageservice 6.3.10, @vscode/emmet-helper 2.11.0) ship optional chaining (?.) in their own published CommonJS builds (vscode-css-languageservice/lib/umd/cssLanguageService.js, @vscode/emmet-helper/lib/cjs/emmetHelper.js), which Node 12 does not parse (SyntaxError: Unexpected token '.') regardless of this package's own build target. 1.0.1's ^6.2.11/^2.9.2 dependency ranges resolve to these same exact versions today (a fresh npm install of 1.0.1 gets vscode-css-languageservice@6.3.10 and @vscode/emmet-helper@2.11.0, identical to this package's pinned versions), so 1.0.1 fails identically: reproduced against a real tsserver on TypeScript 3.9, 4.4, and 4.9, both packages showing no plugin activation (log: "Failed to load module '@styled/typescript-styled-plugin'... vscode-css-languageservice/lib/umd/cssLanguageService.js... SyntaxError: Unexpected token '.'" for 1.0.1; this package's own bundle fails one line into its own code for the same underlying reason, since its build target permits, rather than removes, optional chaining). Node 14.21.3 is therefore the actual common floor, not Node 16.20.2: 1.0.1's own actual floor, even though 1.0.1 never declared one.
  • ./api is a real dual build: import gets lib/esm/api.mjs, require gets lib/esm/api.cjs, a genuine CommonJS bundle. Measured with the packed tarball's ./api, both require() and import() succeed on Node 14.21.3, 16.20.2, 18.20.5, 20.18.1, 22.12.0, and 24.11.0 (the same Node floor as the package root, with no require(esm) dependency).
  • require() of an ES module throws ERR_REQUIRE_ASYNC_MODULE if the module graph uses top-level await. Keep the lib/esm/api.mjs bundle free of it.
  • export { value as 'module.exports' } sets what require() of an ES module returns; named exports are then invisible to CommonJS callers. Not used by this package (see the TypeScript-versions note above); documented here because require(esm) interop depends on it.
  • Host runtimes: VS Code and Cursor run tsserver on their Electron Node (24.x in 2026). typescript-language-server 6.0.1 requires Node 22.22.2 or newer. @vtsls/language-server declares Node 18 or newer, so a vtsls user on an older system Node cannot load the plugin. typescript.tsserver.nodePath in VS Code substitutes the user's Node.

Dependencies

  • typescript-template-language-service-decorator 2.3.2 (2023) is the last release; the upstream repository is archived, so any fix needs a fork or vendoring. It is CommonJS.
  • vscode-css-languageservice, @vscode/emmet-helper: CommonJS main with an ESM module field and no exports map; Node always loads the CommonJS build.
  • vscode-css-languageservice 6.3.10's runtime enums (CompletionItemKind, DiagnosticSeverity, and similar) are not visible to a Node ESM named import: with no exports map, Node's ESM loader statically analyzes the CommonJS build to synthesize named exports, and that analysis does not find them. Measured on Node 24.11.0: node --input-type=module -e "import { CompletionItemKind } from 'vscode-css-languageservice'" throws SyntaxError: Named export 'CompletionItemKind' not found, while the same import from vscode-languageserver-types 3.18.4 succeeds. This plugin imports those enums from vscode-languageserver-types instead (src/features/completions.ts, src/features/diagnostics.ts, src/features/code-actions.ts), the package vscode-css-languageservice itself depends on for the same numeric values.

Tag matching

The decorator matches a tagged template by the tag's source text. A configured name n matches when the tag text is n, starts with n., n(, n<, or n[, or ends with .n. Only tagged templates are candidates: a plain call like globalStyle('body', {...}) never matches. Short generic names therefore also match unrelated tagged templates that share the prefix.

Downstream

  • styled-components/vscode-styled-components bundles this package and registers it through contributes.typescriptServerPlugins with enableForWorkspaceTypeScriptVersions: true. The extension depends on ^1.0.0; a new major reaches extension users only after the extension updates that range.

Driving tsserver in tests

  • Open the probe file without projectFileName and keep its tsconfig.json in the same directory: passing projectFileName makes tsserver build an inferred project that ignores the config.
  • Derive caret positions from markers in the source, never hand-counted columns, and pair every assertion that expects nothing (no diagnostics, no completions) with a positive control in the same session, since a plugin that failed to load also returns nothing.
  • Escape U+2028 and U+2029 in request JSON; the line-delimited protocol treats them as line breaks.
  • Match responses by request_seq, not by command name.
  • A notification (open, change) gets a response on TypeScript 5.9.3 and 6.0.3 and none on 5.5.4, 5.0.4, and 4.9.5 (Session.notRequired), so a harness awaits only requests and discards a notification's response when one arrives.
  • Sending open with fileContent for a file that is already open replaces its text and marks its projects dirty (ScriptInfo.open, 4.9.5 through 6.0.3), so one tsserver can serve many cases that each reopen the file with their own content.
  • tsserver tries --pluginProbeLocations before its own install's node_modules (the log line "Enabling plugin ... from candidate paths"), so the e2e suite loads the plugin through the test/e2e workspace link; the e2e globalSetup (test/e2e/tsserver-fixture/check-plugin-link.ts) fails the run when that link resolves outside this repository.
  • To find tests that pass without the plugin, point that link at a stub package whose factory's create returns info.languageService unchanged (and turn the link check off for the run): only tests that never depend on the plugin (TypeScript's own completions inside a placeholder, a missing-plugin project, the marker helper's own tests) may still pass.