diff --git a/packages/language-server/lib/hybridModeProject.ts b/packages/language-server/lib/hybridModeProject.ts index 9da028c14e..4ae11c3075 100644 --- a/packages/language-server/lib/hybridModeProject.ts +++ b/packages/language-server/lib/hybridModeProject.ts @@ -2,7 +2,7 @@ import type { Language, LanguagePlugin, LanguageServer, LanguageServerProject, P import { createLanguageServiceEnvironment } from '@volar/language-server/lib/project/simpleProject'; import { createLanguage } from '@vue/language-core'; import { createLanguageService, createUriMap, LanguageService } from '@vue/language-service'; -import { searchNamedPipeServerForFile, readPipeTable } from '@vue/typescript-plugin/lib/utils'; +import { getReadyNamedPipePaths, onSomePipeReadyCallbacks, searchNamedPipeServerForFile } from '@vue/typescript-plugin/lib/utils'; import { URI } from 'vscode-uri'; export function createHybridModeProject( @@ -17,26 +17,39 @@ export function createHybridModeProject( }): void; }> ): LanguageServerProject { - let initialized = false; let simpleLs: Promise | undefined; let server: LanguageServer; - let pipeTableWatcher: NodeJS.Timeout | undefined; const tsconfigProjects = createUriMap>(); const project: LanguageServerProject = { setup(_server) { server = _server; + onSomePipeReadyCallbacks.push(() => { + server.refresh(project); + }); + server.onDidChangeWatchedFiles(({ changes }) => { + for (const change of changes) { + const changeUri = URI.parse(change.uri); + if (tsconfigProjects.has(changeUri)) { + tsconfigProjects.get(changeUri)?.then(project => project.dispose()); + tsconfigProjects.delete(changeUri); + server.clearPushDiagnostics(); + } + } + }); + const end = Date.now() + 60000; + const pipeWatcher = setInterval(() => { + getReadyNamedPipePaths(); + if (Date.now() > end) { + clearInterval(pipeWatcher!); + } + }, 1000); }, async getLanguageService(uri) { - if (!initialized) { - initialized = true; - initialize(); - trackPipeTableChanges(); - } const fileName = asFileName(uri); - const projectInfo = (await searchNamedPipeServerForFile(fileName))?.projectInfo; - if (projectInfo?.kind === 1) { - const tsconfig = projectInfo.name; + const namedPipeServer = (await searchNamedPipeServerForFile(fileName)); + if (namedPipeServer?.projectInfo?.kind === 1) { + const tsconfig = namedPipeServer.projectInfo.name; const tsconfigUri = URI.file(tsconfig); if (!tsconfigProjects.has(tsconfigUri)) { tsconfigProjects.set(tsconfigUri, createLs(server, tsconfig)); @@ -63,10 +76,6 @@ export function createHybridModeProject( } tsconfigProjects.clear(); simpleLs = undefined; - if (pipeTableWatcher) { - clearInterval(pipeTableWatcher); - pipeTableWatcher = undefined; - } }, }; @@ -76,39 +85,6 @@ export function createHybridModeProject( return uri.fsPath.replace(/\\/g, '/'); } - function initialize() { - server.onDidChangeWatchedFiles(({ changes }) => { - for (const change of changes) { - const changeUri = URI.parse(change.uri); - if (tsconfigProjects.has(changeUri)) { - tsconfigProjects.get(changeUri)?.then(project => project.dispose()); - tsconfigProjects.delete(changeUri); - server.clearPushDiagnostics(); - } - } - }); - } - - function trackPipeTableChanges() { - if (pipeTableWatcher) { - clearInterval(pipeTableWatcher); - pipeTableWatcher = undefined; - } - let table = readPipeTable(); - let remaining = 20; - pipeTableWatcher = setInterval(() => { - const newTable = readPipeTable(); - if (JSON.stringify(table) !== JSON.stringify(newTable)) { - table = newTable; - server.refresh(project); - } - if (remaining-- <= 0) { - clearInterval(pipeTableWatcher); - pipeTableWatcher = undefined; - } - }, 1000); - } - async function createLs(server: LanguageServer, tsconfig: string | undefined) { const { languagePlugins, setup } = await create({ configFileName: tsconfig, diff --git a/packages/typescript-plugin/index.ts b/packages/typescript-plugin/index.ts index 9545d8f3c5..baa5b8a4db 100644 --- a/packages/typescript-plugin/index.ts +++ b/packages/typescript-plugin/index.ts @@ -1,7 +1,7 @@ import { createLanguageServicePlugin, externalFiles } from '@volar/typescript/lib/quickstart/createLanguageServicePlugin'; import * as vue from '@vue/language-core'; import { proxyLanguageServiceForVue } from './lib/common'; -import { projects, startNamedPipeServer } from './lib/server'; +import { startNamedPipeServer } from './lib/server'; const windowsPathReg = /\\/g; @@ -25,10 +25,13 @@ const plugin = createLanguageServicePlugin( return { languagePlugins: [languagePlugin], setup: language => { - projects.set(info.project, { info, language, vueOptions }); - info.languageService = proxyLanguageServiceForVue(ts, language, info.languageService, vueOptions, fileName => fileName); - startNamedPipeServer(ts, info.project.projectKind, info.project.getCurrentDirectory()); + if ( + info.project.projectKind === ts.server.ProjectKind.Configured + || info.project.projectKind === ts.server.ProjectKind.Inferred + ) { + startNamedPipeServer(ts, info, language, info.project.projectKind); + } // #3963 const timer = setInterval(() => { diff --git a/packages/typescript-plugin/lib/client.ts b/packages/typescript-plugin/lib/client.ts index 0647aed563..ce8d82db99 100644 --- a/packages/typescript-plugin/lib/client.ts +++ b/packages/typescript-plugin/lib/client.ts @@ -1,5 +1,5 @@ import type { Request } from './server'; -import { connect, searchNamedPipeServerForFile, sendRequestWorker } from './utils'; +import { searchNamedPipeServerForFile, sendRequestWorker } from './utils'; export function collectExtractProps( ...args: Parameters @@ -85,15 +85,12 @@ export function getElementAttrs( } async function sendRequest(request: Request) { - const server = (await searchNamedPipeServerForFile(request.args[0]))?.server; + const server = (await searchNamedPipeServerForFile(request.args[0])); if (!server) { console.warn('[Vue Named Pipe Client] No server found for', request.args[0]); return; } - const client = await connect(server.path); - if (!client) { - console.warn('[Vue Named Pipe Client] Failed to connect to', server.path); - return; - } - return await sendRequestWorker(request, client); + const res = await sendRequestWorker(request, server.socket); + server.socket.end(); + return res; } diff --git a/packages/typescript-plugin/lib/server.ts b/packages/typescript-plugin/lib/server.ts index 389fe87ca4..ec3d9d1e94 100644 --- a/packages/typescript-plugin/lib/server.ts +++ b/packages/typescript-plugin/lib/server.ts @@ -1,4 +1,4 @@ -import type { Language, VueCompilerOptions } from '@vue/language-core'; +import type { Language } from '@vue/language-core'; import * as fs from 'fs'; import * as net from 'net'; import type * as ts from 'typescript'; @@ -8,10 +8,11 @@ import { getImportPathForFile } from './requests/getImportPathForFile'; import { getPropertiesAtLocation } from './requests/getPropertiesAtLocation'; import { getQuickInfoAtPosition } from './requests/getQuickInfoAtPosition'; import type { RequestContext } from './requests/types'; -import { NamedPipeServer, connect, readPipeTable, updatePipeTable } from './utils'; +import { connect, getNamedPipePath } from './utils'; export interface Request { - type: 'projectInfoForFile' + type: 'containsFile' + | 'projectInfo' | 'collectExtractProps' | 'getImportPathForFile' | 'getPropertiesAtLocation' @@ -25,139 +26,130 @@ export interface Request { args: [fileName: string, ...rest: any]; } -let started = false; +export interface ProjectInfo { + name: string; + kind: ts.server.ProjectKind; + currentDirectory: string; +} -export function startNamedPipeServer( +export async function startNamedPipeServer( ts: typeof import('typescript'), - serverKind: ts.server.ProjectKind, - currentDirectory: string + info: ts.server.PluginCreateInfo, + language: Language, + projectKind: ts.server.ProjectKind.Inferred | ts.server.ProjectKind.Configured ) { - if (started) { - return; - } - started = true; - - const pipeFile = process.platform === 'win32' - ? `\\\\.\\pipe\\vue-tsp-${process.pid}` - : `/tmp/vue-tsp-${process.pid}`; const server = net.createServer(connection => { connection.on('data', data => { const text = data.toString(); + if (text === 'ping') { + connection.write('pong'); + return; + } const request: Request = JSON.parse(text); const fileName = request.args[0]; - const project = getProject(ts.server.toNormalizedPath(fileName)); - if (request.type === 'projectInfoForFile') { - connection.write( - JSON.stringify( - project - ? { - name: project.info.project.getProjectName(), - kind: project.info.project.projectKind, - } - : null - ) + if (request.type === 'containsFile') { + sendResponse( + info.project.containsFile(ts.server.toNormalizedPath(fileName)) ); } - else if (project) { - const requestContext: RequestContext = { - typescript: ts, - languageService: project.info.languageService, - languageServiceHost: project.info.languageServiceHost, - language: project.language, - isTsPlugin: true, - getFileId: (fileName: string) => fileName, - }; - if (request.type === 'collectExtractProps') { - const result = collectExtractProps.apply(requestContext, request.args as any); - connection.write(JSON.stringify(result ?? null)); - } - else if (request.type === 'getImportPathForFile') { - const result = getImportPathForFile.apply(requestContext, request.args as any); - connection.write(JSON.stringify(result ?? null)); - } - else if (request.type === 'getPropertiesAtLocation') { - const result = getPropertiesAtLocation.apply(requestContext, request.args as any); - connection.write(JSON.stringify(result ?? null)); - } - else if (request.type === 'getQuickInfoAtPosition') { - const result = getQuickInfoAtPosition.apply(requestContext, request.args as any); - connection.write(JSON.stringify(result ?? null)); - } - // Component Infos - else if (request.type === 'getComponentProps') { - const result = getComponentProps.apply(requestContext, request.args as any); - connection.write(JSON.stringify(result ?? null)); - } - else if (request.type === 'getComponentEvents') { - const result = getComponentEvents.apply(requestContext, request.args as any); - connection.write(JSON.stringify(result ?? null)); - } - else if (request.type === 'getTemplateContextProps') { - const result = getTemplateContextProps.apply(requestContext, request.args as any); - connection.write(JSON.stringify(result ?? null)); - } - else if (request.type === 'getComponentNames') { - const result = getComponentNames.apply(requestContext, request.args as any); - connection.write(JSON.stringify(result ?? null)); - } - else if (request.type === 'getElementAttrs') { - const result = getElementAttrs.apply(requestContext, request.args as any); - connection.write(JSON.stringify(result ?? null)); - } - else { - console.warn('[Vue Named Pipe Server] Unknown request type:', request.type); - } + if (request.type === 'projectInfo') { + sendResponse({ + name: info.project.getProjectName(), + kind: info.project.projectKind, + currentDirectory: info.project.getCurrentDirectory(), + } satisfies ProjectInfo); + } + const requestContext: RequestContext = { + typescript: ts, + languageService: info.languageService, + languageServiceHost: info.languageServiceHost, + language: language, + isTsPlugin: true, + getFileId: (fileName: string) => fileName, + }; + if (request.type === 'collectExtractProps') { + const result = collectExtractProps.apply(requestContext, request.args as any); + sendResponse(result); + } + else if (request.type === 'getImportPathForFile') { + const result = getImportPathForFile.apply(requestContext, request.args as any); + sendResponse(result); + } + else if (request.type === 'getPropertiesAtLocation') { + const result = getPropertiesAtLocation.apply(requestContext, request.args as any); + sendResponse(result); + } + else if (request.type === 'getQuickInfoAtPosition') { + const result = getQuickInfoAtPosition.apply(requestContext, request.args as any); + sendResponse(result); + } + // Component Infos + else if (request.type === 'getComponentProps') { + const result = getComponentProps.apply(requestContext, request.args as any); + sendResponse(result); + } + else if (request.type === 'getComponentEvents') { + const result = getComponentEvents.apply(requestContext, request.args as any); + sendResponse(result); + } + else if (request.type === 'getTemplateContextProps') { + const result = getTemplateContextProps.apply(requestContext, request.args as any); + sendResponse(result); + } + else if (request.type === 'getComponentNames') { + const result = getComponentNames.apply(requestContext, request.args as any); + sendResponse(result); + } + else if (request.type === 'getElementAttrs') { + const result = getElementAttrs.apply(requestContext, request.args as any); + sendResponse(result); } else { - console.warn('[Vue Named Pipe Server] No project found for:', fileName); + console.warn('[Vue Named Pipe Server] Unknown request type:', request.type); } - connection.end(); }); connection.on('error', err => console.error('[Vue Named Pipe Server]', err.message)); - }); - cleanupPipeTable(); - - const table = readPipeTable(); - table.push({ - path: pipeFile, - serverKind, - currentDirectory, + function sendResponse(data: any | undefined) { + connection.write(JSON.stringify(data ?? null) + '\n\n'); + } }); - updatePipeTable(table); - try { - fs.unlinkSync(pipeFile); - } catch { } - - server.listen(pipeFile); -} - -function cleanupPipeTable() { - for (const server of readPipeTable()) { - connect(server.path).then(client => { - if (client) { - client.end(); - } - else { - let table: NamedPipeServer[] = readPipeTable(); - table = table.filter(item => item.path !== server.path); - updatePipeTable(table); - } - }); + for (let i = 0; i < 20; i++) { + const path = getNamedPipePath(projectKind, i); + const socket = await connect(path, 100); + if (typeof socket === 'object') { + socket.end(); + } + const namedPipeOccupied = typeof socket === 'object' || socket === 'timeout'; + if (namedPipeOccupied) { + continue; + } + const success = await tryListen(server, path); + if (success) { + break; + } } } -export const projects = new Map; - vueOptions: VueCompilerOptions; -}>(); - -function getProject(filename: ts.server.NormalizedPath) { - for (const [project, data] of projects) { - if (project.containsFile(filename)) { - return data; - } - } +function tryListen(server: net.Server, namedPipePath: string) { + return new Promise(resolve => { + const onSuccess = () => { + server.off('error', onError); + resolve(true); + }; + const onError = (err: any) => { + if ((err as any).code === 'ECONNREFUSED') { + try { + console.log('[Vue Named Pipe Client] Deleting:', namedPipePath); + fs.promises.unlink(namedPipePath); + } catch { } + } + server.off('error', onError); + server.close(); + resolve(false); + }; + server.listen(namedPipePath, onSuccess); + server.on('error', onError); + }); } diff --git a/packages/typescript-plugin/lib/utils.ts b/packages/typescript-plugin/lib/utils.ts index f6152cf0bf..50907960d6 100644 --- a/packages/typescript-plugin/lib/utils.ts +++ b/packages/typescript-plugin/lib/utils.ts @@ -1,89 +1,207 @@ -import * as os from 'os'; +import * as fs from 'fs'; import * as net from 'net'; +import * as os from 'os'; import * as path from 'path'; import type * as ts from 'typescript'; -import * as fs from 'fs'; -import type { Request } from './server'; +import type { ProjectInfo, Request } from './server'; export { TypeScriptProjectHost } from '@volar/typescript'; -export interface NamedPipeServer { - path: string; - serverKind: ts.server.ProjectKind; - currentDirectory: string; -} - const { version } = require('../package.json'); - -const pipeTableFile = path.join(os.tmpdir(), `vue-tsp-table-${version}.json`); - -export function readPipeTable() { - if (!fs.existsSync(pipeTableFile)) { - return []; +const platform = os.platform(); +const pipeDir = platform === 'win32' + ? `\\\\.\\pipe` + : `/tmp`; +const toFullPath = (file: string) => { + if (platform === 'win32') { + return pipeDir + '\\' + file; } - try { - const servers: NamedPipeServer[] = JSON.parse(fs.readFileSync(pipeTableFile, 'utf8')); - return servers; - } catch { - fs.unlinkSync(pipeTableFile); - return []; + else { + return pipeDir + '/' + file; } +}; +const configuredNamedPipePathPrefix = toFullPath(`vue-named-pipe-${version}-configured-`); +const inferredNamedPipePathPrefix = toFullPath(`vue-named-pipe-${version}-inferred-`); +const pipes = new Map(); + +export const onSomePipeReadyCallbacks: (() => void)[] = []; + +function watchNamedPipeReady(namedPipePath: string) { + const socket = net.connect(namedPipePath); + const start = Date.now(); + socket.on('connect', () => { + console.log('[Vue Named Pipe Client] Connected:', namedPipePath, 'in', (Date.now() - start) + 'ms'); + socket.write('ping'); + }); + socket.on('data', () => { + console.log('[Vue Named Pipe Client] Ready:', namedPipePath, 'in', (Date.now() - start) + 'ms'); + pipes.set(namedPipePath, 'ready'); + socket.end(); + onSomePipeReadyCallbacks.forEach(cb => cb()); + }); + socket.on('error', (err) => { + if ((err as any).code === 'ECONNREFUSED') { + try { + console.log('[Vue Named Pipe Client] Deleting:', namedPipePath); + fs.promises.unlink(namedPipePath); + } catch { } + } + pipes.delete(namedPipePath); + socket.end(); + }); } -export function updatePipeTable(servers: NamedPipeServer[]) { - if (servers.length === 0) { - fs.unlinkSync(pipeTableFile); - } - else { - fs.writeFileSync(pipeTableFile, JSON.stringify(servers, undefined, 2)); +export function getNamedPipePath(projectKind: ts.server.ProjectKind.Configured | ts.server.ProjectKind.Inferred, key: number) { + return projectKind === 1 satisfies ts.server.ProjectKind.Configured + ? `${configuredNamedPipePathPrefix}${key}` + : `${inferredNamedPipePathPrefix}${key}`; +} + +export async function getReadyNamedPipePaths() { + const configuredPipes: string[] = []; + const inferredPipes: string[] = []; + for (let i = 0; i < 20; i++) { + const configuredPipe = getNamedPipePath(1 satisfies ts.server.ProjectKind.Configured, i); + const inferredPipe = getNamedPipePath(0 satisfies ts.server.ProjectKind.Inferred, i); + if (pipes.get(configuredPipe) === 'ready') { + configuredPipes.push(configuredPipe); + } + else if (!pipes.has(configuredPipe)) { + pipes.set(configuredPipe, 'unknown'); + watchNamedPipeReady(configuredPipe); + } + if (pipes.get(inferredPipe) === 'ready') { + inferredPipes.push(inferredPipe); + } + else if (!pipes.has(inferredPipe)) { + pipes.set(inferredPipe, 'unknown'); + watchNamedPipeReady(inferredPipe); + } } + return { + configured: configuredPipes, + inferred: inferredPipes, + }; } -export function connect(path: string) { - return new Promise(resolve => { - const client = net.connect(path); - client.setTimeout(1000); - client.on('connect', () => { - resolve(client); - }); - client.on('error', () => { - return resolve(undefined); - }); - client.on('timeout', () => { - return resolve(undefined); - }); +export async function connect(namedPipePath: string, timeout?: number) { + return new Promise(resolve => { + const socket = net.connect(namedPipePath); + if (timeout) { + socket.setTimeout(timeout); + } + const onConnect = () => { + cleanup(); + resolve(socket); + }; + const onError = (err: any) => { + if ((err as any).code === 'ECONNREFUSED') { + try { + console.log('[Vue Named Pipe Client] Deleting:', namedPipePath); + fs.promises.unlink(namedPipePath); + } catch { } + } + pipes.delete(namedPipePath); + cleanup(); + resolve('error'); + }; + const onTimeout = () => { + cleanup(); + resolve('timeout'); + } + const cleanup = () => { + socket.off('connect', onConnect); + socket.off('error', onError); + socket.off('timeout', onTimeout); + }; + socket.on('connect', onConnect); + socket.on('error', onError); + socket.on('timeout', onTimeout); }); } export async function searchNamedPipeServerForFile(fileName: string) { - const servers = readPipeTable(); - const configuredServers = servers - .filter(item => item.serverKind === 1 satisfies ts.server.ProjectKind.Configured); - const inferredServers = servers - .filter(item => item.serverKind === 0 satisfies ts.server.ProjectKind.Inferred) - .sort((a, b) => b.currentDirectory.length - a.currentDirectory.length); - for (const server of configuredServers.sort((a, b) => sortTSConfigs(fileName, a.currentDirectory, b.currentDirectory))) { - const client = await connect(server.path); - if (client) { - const projectInfo = await sendRequestWorker<{ name: string; kind: ts.server.ProjectKind; }>({ type: 'projectInfoForFile', args: [fileName] }, client); - if (projectInfo) { - return { - server, - projectInfo, - }; + const paths = await getReadyNamedPipePaths(); + + const configuredServers = (await Promise.all( + paths.configured.map(async path => { + // Find existing servers + const socket = await connect(path); + if (typeof socket !== 'object') { + return; + } + + // Find servers containing the current file + const containsFile = await sendRequestWorker({ type: 'containsFile' satisfies Request['type'], args: [fileName] }, socket); + if (!containsFile) { + socket.end(); + return; } + + // Get project info for each server + const projectInfo = await sendRequestWorker({ type: 'projectInfo' satisfies Request['type'], args: [fileName] }, socket); + if (!projectInfo) { + socket.end(); + return; + } + + return { + socket, + projectInfo, + }; + }), + )).filter(server => !!server); + + // Sort servers by tsconfig + configuredServers.sort((a, b) => sortTSConfigs(fileName, a.projectInfo.name, b.projectInfo.name)); + + if (configuredServers.length) { + // Close all but the first server + for (let i = 1; i < configuredServers.length; i++) { + configuredServers[i].socket.end(); } + // Return the first server + return configuredServers[0]; } - for (const server of inferredServers) { - if (!path.relative(server.currentDirectory, fileName).startsWith('..')) { - const client = await connect(server.path); - if (client) { + + const inferredServers = (await Promise.all( + paths.inferred.map(async namedPipePath => { + // Find existing servers + const socket = await connect(namedPipePath); + if (typeof socket !== 'object') { + return; + } + + // Get project info for each server + const projectInfo = await sendRequestWorker({ type: 'projectInfo' satisfies Request['type'], args: [fileName] }, socket); + if (!projectInfo) { + socket.end(); + return; + } + + // Check if the file is in the project's directory + if (!path.relative(projectInfo.currentDirectory, fileName).startsWith('..')) { return { - server, - projectInfo: undefined, + socket, + projectInfo, }; } + }), + )).filter(server => !!server); + + // Sort servers by directory + inferredServers.sort((a, b) => + b.projectInfo.currentDirectory.replace(/\\/g, '/').split('/').length + - a.projectInfo.currentDirectory.replace(/\\/g, '/').split('/').length + ); + + if (inferredServers.length) { + // Close all but the first server + for (let i = 1; i < inferredServers.length; i++) { + inferredServers[i].socket.end(); } + // Return the first server + return inferredServers[0]; } } @@ -109,39 +227,35 @@ function isFileInDir(fileName: string, dir: string) { return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); } -export function sendRequestWorker(request: Request, client: net.Socket) { +export function sendRequestWorker(request: Request, socket: net.Socket) { return new Promise(resolve => { let dataChunks: Buffer[] = []; - client.setTimeout(5000); - client.on('data', chunk => { + const onData = (chunk: Buffer) => { dataChunks.push(chunk); - }); - client.on('end', () => { - if (!dataChunks.length) { - console.warn('[Vue Named Pipe Client] No response from server for request:', request.type); - resolve(undefined); - return; - } const data = Buffer.concat(dataChunks); const text = data.toString(); - let json = null; - try { - json = JSON.parse(text); - } catch (e) { - console.error('[Vue Named Pipe Client] Failed to parse response:', text); - resolve(undefined); - return; + if (text.endsWith('\n\n')) { + let json = null; + try { + json = JSON.parse(text); + } catch (e) { + console.error('[Vue Named Pipe Client] Failed to parse response:', text); + } + cleanup(); + resolve(json); } - resolve(json); - }); - client.on('error', err => { + }; + const onError = (err: any) => { console.error('[Vue Named Pipe Client] Error:', err.message); + cleanup(); resolve(undefined); - }); - client.on('timeout', () => { - console.error('[Vue Named Pipe Client] Timeout'); - resolve(undefined); - }); - client.write(JSON.stringify(request)); + }; + const cleanup = () => { + socket.off('data', onData); + socket.off('error', onError); + }; + socket.on('data', onData); + socket.on('error', onError); + socket.write(JSON.stringify(request)); }); }