From 54a48c95eba656b15e10ecea06b3c5fc5c1f8046 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 1 Aug 2026 15:29:11 +0530 Subject: [PATCH 1/2] fix: lsp servers failing to start in the packaged desktop app Packaging dereferences the symlinks npm creates in node_modules/.bin, so the shim body ends up in .bin/ where its own relative require resolves against node_modules/ - vtsls died with "Cannot find module '../dist/main.js'". vscode-json-language-server broke the same way. Resolve the bin entry the bundled package itself declares and run it on our node runtime instead of going through the .bin shim. This is also correct on Windows, where .bin/ is an sh script that cannot be spawned directly. --- src-node/lsp-client.js | 110 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/src-node/lsp-client.js b/src-node/lsp-client.js index 06ffbf6205..d01506a070 100644 --- a/src-node/lsp-client.js +++ b/src-node/lsp-client.js @@ -45,8 +45,9 @@ * - 'serverExit' { serverId, code } * - 'serverError' { serverId, error } * - * Server resolution order when starting: `src-node/node_modules/.bin/` (bundled), - * then the system PATH. Messages use JSON-RPC 2.0 over stdio with Content-Length headers. + * Server resolution order when starting: the `bin` entry a bundled `src-node/node_modules` package + * declares for , then the system PATH. Messages use JSON-RPC 2.0 over stdio with + * Content-Length headers. */ // Create connector at module load time (same pattern as src-node/git/cli.js) @@ -56,8 +57,97 @@ const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); -// Path to node_modules/.bin for bundled LSP servers -const NODE_MODULES_BIN = path.join(__dirname, 'node_modules', '.bin'); +// Paths used to locate bundled LSP servers +const NODE_MODULES = path.join(__dirname, 'node_modules'); +const NODE_MODULES_BIN = path.join(NODE_MODULES, '.bin'); + +// command name -> absolute path of the JS entry the bundled package declares for it. Built lazily +// on first use and kept for the life of the process - node_modules does not change under us. +let bundledBinIndex = null; + +function _readDirSafe(dir) { + try { + return fs.readdirSync(dir); + } catch (err) { + return []; + } +} + +function _indexPackageBins(index, pkgDir) { + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); + } catch (err) { + return; // not a package, or unreadable - nothing to index + } + if (typeof pkg.bin === 'string' && pkg.name) { + // shorthand form: the single bin is named after the package ("@scope/name" -> "name") + index.set(path.basename(pkg.name), path.resolve(pkgDir, pkg.bin)); + } else if (pkg.bin && typeof pkg.bin === 'object') { + for (const [binName, binPath] of Object.entries(pkg.bin)) { + index.set(binName, path.resolve(pkgDir, binPath)); + } + } +} + +// Bundled bins are often extensionless (typescript's `tsserver`, vscode-langservers-extracted's +// `vscode-json-language-server`), so the extension alone cannot tell us whether our node runtime +// can run the file - fall back to sniffing the shebang. +function _isNodeScript(filePath) { + if (/\.(js|cjs|mjs)$/.test(filePath)) { + return fs.existsSync(filePath); + } + let fd; + try { + fd = fs.openSync(filePath, 'r'); + const buffer = Buffer.alloc(128); + const bytesRead = fs.readSync(fd, buffer, 0, 128, 0); + const firstLine = buffer.slice(0, bytesRead).toString('utf8').split('\n')[0]; + return firstLine.startsWith('#!') && firstLine.includes('node'); + } catch (err) { + return false; // missing or unreadable + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + } +} + +/** + * Resolves a bundled server command to the JS entry its own package declares in `bin`. + * + * We deliberately avoid running `node_modules/.bin/`: npm puts a symlink there on POSIX + * (and an sh/.cmd shim pair on Windows), and packaging the desktop app dereferences symlinks - the + * shim's *body* is then copied into .bin/, where its own relative require ("../dist/main.js") + * resolves against node_modules/ instead of the package folder and the server dies at startup with + * MODULE_NOT_FOUND. The package's real entry point has no such ambiguity, on any platform. + * + * @param {string} command - bare command name, e.g. "vtsls" + * @returns {string|null} absolute path to a .js entry, or null if no bundled package declares it + */ +function _resolveBundledServerEntry(command) { + if (!bundledBinIndex) { + bundledBinIndex = new Map(); + for (const entry of _readDirSafe(NODE_MODULES)) { + if (entry.startsWith('.')) { + continue; + } + if (entry.startsWith('@')) { + for (const scopedPkg of _readDirSafe(path.join(NODE_MODULES, entry))) { + _indexPackageBins(bundledBinIndex, path.join(NODE_MODULES, entry, scopedPkg)); + } + } else { + _indexPackageBins(bundledBinIndex, path.join(NODE_MODULES, entry)); + } + } + } + const entryPath = bundledBinIndex.get(command); + // only a node script can run on our node runtime - a native bin is left to the .bin lookup + if (entryPath && _isNodeScript(entryPath)) { + return entryPath; + } + return null; +} // Registry of active servers: serverId -> serverState const servers = new Map(); @@ -269,8 +359,10 @@ exports.startServer = async function startServer(params) { // outside src-node) runs on our own node runtime - process.execPath is phnode itself, the // same spawn-self pattern _npmInstallInFolder and the ESLint service use. This sidesteps // node_modules/.bin shims entirely (they are sh scripts / .cmd on Windows). - // 2. A server bundled in src-node/node_modules/.bin. - // 3. Fall back to spawning the command as given - which also covers an absolute path to a + // 2. A server bundled in src-node/node_modules: run the JS entry its package declares in `bin` + // on our own node runtime, for the same reason - see _resolveBundledServerEntry(). + // 3. A bundled non-JS bin, via the node_modules/.bin shim. + // 4. Fall back to spawning the command as given - which also covers an absolute path to a // native binary (e.g. a user-installed server like pyrefly), spawned as-is, or a PATH // lookup for a bare command name. let commandPath = command; @@ -279,8 +371,12 @@ exports.startServer = async function startServer(params) { spawnArgs = [command, ...args]; commandPath = process.execPath; } else { + const bundledEntry = _resolveBundledServerEntry(command); const localBinPath = path.join(NODE_MODULES_BIN, command); - if (fs.existsSync(localBinPath)) { + if (bundledEntry) { + spawnArgs = [bundledEntry, ...args]; + commandPath = process.execPath; + } else if (fs.existsSync(localBinPath)) { commandPath = localBinPath; } } From 7830ddb87047f41ca90bb8d8f8caaff92942f1e0 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 1 Aug 2026 15:35:01 +0530 Subject: [PATCH 2/2] docs: update geenrated editor.js docs --- docs/API-Reference/editor/Editor.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/API-Reference/editor/Editor.md b/docs/API-Reference/editor/Editor.md index 43d92ec756..4db5c3420a 100644 --- a/docs/API-Reference/editor/Editor.md +++ b/docs/API-Reference/editor/Editor.md @@ -807,7 +807,7 @@ making the selection | [end] | Object | If not specified, defaults to start. | | [center] | boolean | true to center the viewport | | [centerOptions] | number | Option value, or 0 for no options; one of the BOUNDARY_* constants above. | -| [origin] | string | An optional string that describes what other selection or edit operations this should be merged with for the purposes of undo. See [Document::Document#replaceRange](Document::Document#replaceRange) for more details. | +| [origin] | string | An optional string that describes what other selection or edit operations this should be merged with for the purposes of undo. See `Document#replaceRange` for more details. | @@ -891,7 +891,7 @@ Optionally centers around the primary selection after making the selection. | selections | Object | The selection ranges to set. If the start and end of a range are the same, treated as a cursor. If reversed is true, set the anchor of the range to the end instead of the start. If primary is true, this is the primary selection. Behavior is undefined if more than one selection has primary set to true. If none has primary set to true, the last one is primary. | | center | boolean | true to center the viewport around the primary selection. | | centerOptions | number | Option value, or 0 for no options; one of the BOUNDARY_* constants above. | -| origin | string | An optional string that describes what other selection or edit operations this should be merged with for the purposes of undo. See [Document::Document#replaceRange](Document::Document#replaceRange) for more details. | +| origin | string | An optional string that describes what other selection or edit operations this should be merged with for the purposes of undo. See `Document#replaceRange` for more details. | @@ -1174,7 +1174,7 @@ A-B-A would return A as the mode, not null). **Kind**: instance method of [Editor](#Editor) **Returns**: Object \| string - Name of syntax-highlighting mode, or object containing a "name" property naming the mode along with configuration options required by the mode. -**See**: [LanguageManager::#getLanguageForPath](LanguageManager::#getLanguageForPath) and [LanguageManager::Language#getMode](LanguageManager::Language#getMode). +**See**: [LanguageManager::#getLanguageForPath](LanguageManager::#getLanguageForPath) and `Language#getMode`. | Param | Type | Description | | --- | --- | --- | @@ -1195,7 +1195,7 @@ consistent and resolve to the same mode. **Kind**: instance method of [Editor](#Editor) **Returns**: Object \| string - Name of syntax-highlighting mode, or object containing a "name" property naming the mode along with configuration options required by the mode. -**See**: [LanguageManager::#getLanguageForPath](LanguageManager::#getLanguageForPath) and [LanguageManager::Language#getMode](LanguageManager::Language#getMode). +**See**: [LanguageManager::#getLanguageForPath](LanguageManager::#getLanguageForPath) and `Language#getMode`. | Param | Type | | --- | --- | @@ -1222,7 +1222,7 @@ Gets the syntax-highlighting mode for the document. **Kind**: instance method of [Editor](#Editor) **Returns**: Object \| String - Object or Name of syntax-highlighting mode -**See**: [LanguageManager.getLanguageForPath](LanguageManager::#getLanguageForPath) and [Language.getMode](LanguageManager::Language#getMode). +**See**: [LanguageManager.getLanguageForPath](LanguageManager::#getLanguageForPath) and `Language.getMode`. ### editor.updateLayout([forceRefresh])