From b0cc02048a91d1f932736500427a419c3de9c8fc Mon Sep 17 00:00:00 2001 From: gxcsoccer Date: Sun, 1 Mar 2020 02:39:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=8D=95=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E7=89=88=E6=9C=AC=20tsserver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .autod.conf.js | 6 +- .eslintignore | 2 +- .gitignore | 2 + README.md | 38 +++- example/c.js | 10 + example/client.js | 33 ++++ example/client2.js | 33 ++++ example/index.js | 47 +++++ example/md5.js | 20 ++ example/server.js | 20 ++ example/t.js | 42 +++++ example/test.js | 90 +++++++++ example/tt.js | 42 +++++ example/unix.js | 11 ++ lib/connection.js | 123 +++++++++++++ lib/const.js | 3 + lib/index.d.ts | 30 +++ lib/index.js | 97 ++++++++++ lib/protocol.js | 90 +++++++++ lib/proxy_client.js | 97 ++++++++++ lib/proxy_server.js | 287 +++++++++++++++++++++++++++++ lib/start_server.js | 45 +++++ lib/utils.js | 34 ++++ package.json | 24 ++- test/connection.test.js | 166 +++++++++++++++++ test/fixtures/ts-app/index.ts | 2 + test/fixtures/ts-app/package.json | 3 + test/fixtures/ts-app/tsconfig.json | 30 +++ test/index.test.js | 143 ++++++++++++++ test/proxy.test.js | 261 ++++++++++++++++++++++++++ test/utils.test.js | 59 ++++++ 31 files changed, 1882 insertions(+), 8 deletions(-) create mode 100644 example/c.js create mode 100644 example/client.js create mode 100644 example/client2.js create mode 100644 example/index.js create mode 100644 example/md5.js create mode 100644 example/server.js create mode 100644 example/t.js create mode 100644 example/test.js create mode 100644 example/tt.js create mode 100644 example/unix.js create mode 100644 lib/connection.js create mode 100644 lib/const.js create mode 100644 lib/index.d.ts create mode 100644 lib/index.js create mode 100644 lib/protocol.js create mode 100644 lib/proxy_client.js create mode 100644 lib/proxy_server.js create mode 100644 lib/start_server.js create mode 100644 lib/utils.js create mode 100644 test/connection.test.js create mode 100644 test/fixtures/ts-app/index.ts create mode 100644 test/fixtures/ts-app/package.json create mode 100644 test/fixtures/ts-app/tsconfig.json create mode 100644 test/index.test.js create mode 100644 test/proxy.test.js create mode 100644 test/utils.test.js diff --git a/.autod.conf.js b/.autod.conf.js index 4a82fdb..435032e 100644 --- a/.autod.conf.js +++ b/.autod.conf.js @@ -11,10 +11,14 @@ module.exports = { 'autod', 'egg-ci', 'egg-bin', + 'vscode', 'eslint', 'eslint-config-egg', + 'typescript', 'contributors', ], - keep: [], + keep: [ + 'vscode', + ], semver: [], }; diff --git a/.eslintignore b/.eslintignore index a303c79..65cd4bc 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,5 @@ test/fixtures coverage -examples/**/app/public +example logs run diff --git a/.gitignore b/.gitignore index 6704566..00a6805 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port +.vscode +test/.tmp diff --git a/README.md b/README.md index 6b50126..d96ee44 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # singleton-tsserver -singleton tsserver +单实例的 [tsserver](https://github.com/Microsoft/TypeScript/wiki/Standalone-Server-%28tsserver%29) [![NPM version][npm-image]][npm-url] [![build status][travis-image]][travis-url] @@ -20,3 +20,39 @@ singleton tsserver [snyk-url]: https://snyk.io/test/npm/singleton-tsserver [download-image]: https://img.shields.io/npm/dm/singleton-tsserver.svg?style=flat-square [download-url]: https://npmjs.org/package/singleton-tsserver + +针对同样的参数,只启动一个 tsserver 实例 + +## 用法 + +```js +const ClusterTsServerProcess = require('singleton-tsserver'); + +const options = { + tsServerPath: '', + args: [ + '--useInferredProjectPerProjectRoot', + '--enableTelemetry', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', + ], +}; + +const proc = new ClusterTsServerProcess(options); +proc.stdout.on('data', data => { + console.log(data.toString()); +}); + +proc.write({ + seq: 0, + type: 'request', + command: 'configure', + arguments: { + hostInfo: 'vscode', + preferences: { + providePrefixAndSuffixTextForRename: true, + allowRenameOfImportPath: true, + }, + }, +}); +``` diff --git a/example/c.js b/example/c.js new file mode 100644 index 0000000..8656bbe --- /dev/null +++ b/example/c.js @@ -0,0 +1,10 @@ +'use strict'; + +const net = require('net'); +const path = require('path'); + +const socket = net.connect(path.join(__dirname, 'xxx.sock')); + +socket.once('connect', () => { + console.log('connect'); +}); diff --git a/example/client.js b/example/client.js new file mode 100644 index 0000000..5a0ec1c --- /dev/null +++ b/example/client.js @@ -0,0 +1,33 @@ +'use strict'; + +const path = require('path'); +const TsProxyClient = require('../lib/proxy_client'); + +async function main() { + + const client = new TsProxyClient(); + + client.stdout.on('data', data => { + console.log(data.toString()); + }); + + client.write({ + seq: 1, + type: 'request', + command: 'open', + arguments: { file: path.join(__dirname, '../lib/protocol.js') }, + }); + + client.write({ + seq: 2, + type: 'request', + command: 'quickinfo', + arguments: { + file: path.join(__dirname, '../lib/protocol.js'), + line: 5, + offset: 7, + }, + }); +} + +main(); diff --git a/example/client2.js b/example/client2.js new file mode 100644 index 0000000..0955ae9 --- /dev/null +++ b/example/client2.js @@ -0,0 +1,33 @@ +'use strict'; + +const path = require('path'); +const TsProxyClient = require('../lib/proxy_client'); + +async function main() { + + const client = new TsProxyClient(); + + client.stdout.on('data', data => { + console.log(data.toString()); + }); + + // client.write({ + // seq: 1, + // type: 'request', + // command: 'open', + // arguments: { file: path.join(__dirname, '../lib/protocol.js') }, + // }); + + client.write({ + seq: 2, + type: 'request', + command: 'quickinfo', + arguments: { + file: path.join(__dirname, '../lib/protocol.js'), + line: 5, + offset: 7, + }, + }); +} + +main(); diff --git a/example/index.js b/example/index.js new file mode 100644 index 0000000..0cb7f75 --- /dev/null +++ b/example/index.js @@ -0,0 +1,47 @@ +'use strict'; + +const path = require('path'); +const cp = require('child_process'); +const { PassThrough } = require('stream'); +const { TrServerDecoder } = require('./lib/protocol'); + +const tsServerPath = path.join(path.dirname(require.resolve('typescript')), 'tsserver.js'); + +const proc = cp.fork(tsServerPath, [ + '--useInferredProjectPerProjectRoot', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', +], { + silent: true, +}); + +const pass = new PassThrough(); +const decoder = new TrServerDecoder(); + +proc.stdout.pipe(decoder); + +decoder.on('message', msg => { + console.log(msg); +}); + +function encode(obj) { + return JSON.stringify(obj) + '\r\n'; +} + +proc.stdin.write(encode({ + seq: 1, + type: 'request', + command: 'open', + arguments: { file: path.join(__dirname, 'lib/tsserver_decoder.js') }, +})); + +proc.stdin.write(encode({ + seq: 2, + type: 'request', + command: 'quickinfo', + arguments: { + file: path.join(__dirname, 'lib/tsserver_decoder.js'), + line: 5, + offset: 7, + }, +})); diff --git a/example/md5.js b/example/md5.js new file mode 100644 index 0000000..15b95c1 --- /dev/null +++ b/example/md5.js @@ -0,0 +1,20 @@ +'use strict'; + +const path = require('path'); +const utils = require('../lib/utils'); + +const tsServerPath = path.join(path.dirname(require.resolve('typescript')), 'tsserver.js'); +const args = [ + '--useInferredProjectPerProjectRoot', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', +]; +const tsServerForkOptions = { + silent: true, +}; + +console.log(utils.md5(JSON.stringify({ + tsServerPath, + args, + tsServerForkOptions, +}))); diff --git a/example/server.js b/example/server.js new file mode 100644 index 0000000..9095c99 --- /dev/null +++ b/example/server.js @@ -0,0 +1,20 @@ +'use strict'; + +const path = require('path'); +const TsProxyServer = require('../lib/proxy_server'); + +const tsServerPath = path.join(path.dirname(require.resolve('typescript')), 'tsserver.js'); +const args = [ + '--useInferredProjectPerProjectRoot', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', +]; +const tsServerForkOptions = { + silent: true, +}; + +const server = new TsProxyServer({ + tsServerPath, + args, + tsServerForkOptions, +}); diff --git a/example/t.js b/example/t.js new file mode 100644 index 0000000..dbb64d2 --- /dev/null +++ b/example/t.js @@ -0,0 +1,42 @@ +'use strict'; + +const path = require('path'); +const ClusterTsServerProcess = require('../lib'); + +const tsServerPath = path.join(path.dirname(require.resolve('typescript')), 'tsserver.js'); +const args = [ + '--useInferredProjectPerProjectRoot', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', +]; +const tsServerForkOptions = { + silent: true, +}; + +async function main() { + const proc = new ClusterTsServerProcess({ tsServerPath, args, tsServerForkOptions }); + proc.stdout.on('data', data => { + console.log(data.toString()); + }); + await proc.ready(); + + proc.write({ + seq: 1, + type: 'request', + command: 'open', + arguments: { file: path.join(__dirname, '../lib/protocol.js') }, + }); + + proc.write({ + seq: 2, + type: 'request', + command: 'quickinfo', + arguments: { + file: path.join(__dirname, '../lib/protocol.js'), + line: 5, + offset: 7, + }, + }); +} + +main(); diff --git a/example/test.js b/example/test.js new file mode 100644 index 0000000..c5a43a6 --- /dev/null +++ b/example/test.js @@ -0,0 +1,90 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const ClusterTsServerProcess = require('../lib'); + +const options = { + "tsServerPath": "/Users/gaoxiaochen/projj/gitlab.alipay-inc.com/cloud-ide/api-server/_extensions/kaitian.typescript-language-features-1.37.1-patch.12/node_modules/typescript/lib/tsserver.js", + "args": [ + "--useInferredProjectPerProjectRoot", + "--enableTelemetry", + "--cancellationPipeName", + "/var/folders/q4/4nwl16wn32ndm69rzh1zyvhh0000gn/T/vscode-typescript501/e6ea02d3b441c4be4f4c/tscancellation-6ecdd2ccb16d73006bfb.tmp*", + "--locale", "zh-CN", + "--noGetErrOnBackgroundUpdate", + "--validateDefaultNpmLocation" + ], + "tsServerForkOptions": { + "execArgv": [] + } +} + +const proc = new ClusterTsServerProcess(options); +proc.stdout.on('data', data => { + console.log(data.toString()); +}); + +proc.write({ + seq: 0, + type: 'request', + command: 'configure', + arguments: { + hostInfo: 'vscode', + preferences: { + providePrefixAndSuffixTextForRename: true, + allowRenameOfImportPath: true + } + } +}); + +proc.write({ + seq: 1, + type: 'request', + command: 'compilerOptionsForInferredProjects', + arguments: { + options: { + module: 'commonjs', + target: 'es2016', + jsx: 'preserve', + allowJs: true, + allowSyntheticDefaultImports: true, + allowNonTsExtensions: true + } + } +}); + +proc.write({ + "seq": 2, + "type": "request", + "command": "updateOpen", + "arguments": { + "changedFiles": [], + "closedFiles": [], + "openFiles": [{ + "file": "/Users/gaoxiaochen/projj/gitlab.alipay-inc.com/cloud-ide/api-server/app/controller/home.ts", + "fileContent": fs.readFileSync('/Users/gaoxiaochen/projj/gitlab.alipay-inc.com/cloud-ide/api-server/app/controller/home.ts', 'utf8'), + "scriptKindName": "TS", + "projectRootPath": "/Users/gaoxiaochen/projj/gitlab.alipay-inc.com/cloud-ide/api-server" + }] + } +}); + +// setTimeout(() => { +proc.write({ + seq: 3, + type: 'request', + command: 'geterr', + arguments: { + delay: 0, + files: ['/Users/gaoxiaochen/projj/gitlab.alipay-inc.com/cloud-ide/api-server/app/controller/home.ts'] + } +}); +// }, 5000); + +proc.write({ + seq: 4, + type: 'request', + command: 'getSupportedCodeFixes', + arguments: null +}); diff --git a/example/tt.js b/example/tt.js new file mode 100644 index 0000000..5080820 --- /dev/null +++ b/example/tt.js @@ -0,0 +1,42 @@ +'use strict'; + +const path = require('path'); +const ClusterTsServerProcess = require('../lib'); + +const tsServerPath = path.join(path.dirname(require.resolve('typescript')), 'tsserver.js'); +const args = [ + '--useInferredProjectPerProjectRoot', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', +]; +const tsServerForkOptions = { + silent: true, +}; + +async function main() { + const proc = new ClusterTsServerProcess({ tsServerPath, args, tsServerForkOptions }); + proc.stdout.on('data', data => { + console.log(data.toString()); + }); + await proc.ready(); + + proc.write({ + seq: 1, + type: 'request', + command: 'open', + arguments: { file: path.join(__dirname, '../test/fixtures/ts-app/index.ts') }, + }); + + proc.write({ + seq: 2, + type: 'request', + command: 'quickinfo', + arguments: { + file: path.join(__dirname, '../test/fixtures/ts-app/index.ts'), + line: 2, + offset: 1, + }, + }); +} + +main(); diff --git a/example/unix.js b/example/unix.js new file mode 100644 index 0000000..6cd4d84 --- /dev/null +++ b/example/unix.js @@ -0,0 +1,11 @@ +'use strict'; + +const net = require('net'); +const path = require('path'); + +const server = net.createServer(socket => { + console.log('new socket on', socket.address()); +}); + +// server.listen(path.join(__dirname, 'xxx.sock')); +server.listen('/Users/gaoxiaochen/projj/github.com/gxcsoccer/singleton-tsserver/lib/tmp/c793036fdc9efcc5e529413472bd8593.sock') diff --git a/lib/connection.js b/lib/connection.js new file mode 100644 index 0000000..a90436d --- /dev/null +++ b/lib/connection.js @@ -0,0 +1,123 @@ +'use strict'; + +const uuid = require('uuid'); +const Base = require('sdk-base'); +const assert = require('assert'); +const { pipeline } = require('stream'); +const { TsServerEncoder, TsServerDecoder } = require('./protocol'); + +const defaultOptions = { + maxIdleTime: 120000, // 120s 没有请求断开连接 +}; + +class TsConnection extends Base { + constructor(options = {}) { + assert(options.socket, '[TsConnection] options.socket is '); + super(Object.assign({}, defaultOptions, options)); + this.socket = options.socket; + this.logger = options.logger || console; + this.key = uuid.v4(); + this.peddingReqs = new Map(); + + this.socket.once('close', () => { this._handleClose(); }); + this.socket.once('error', err => { this._handleSocketError(err); }); + this.encoder = new TsServerEncoder(); + this.decoder = new TsServerDecoder(); + this.decoder.on('message', msg => { this._dispatchMessage(msg); }); + + pipeline(this.encoder, this.socket, this.decoder, err => { + if (err) { + this._handleSocketError(err); + } + }); + + this._lastActiveTime = Date.now(); + this._timer = setInterval(() => { + const now = Date.now(); + if (now - this.lastActiveTime >= this.options.maxIdleTime) { + this.logger.warn('[TsConnection] socket: %s is idle for %s(ms), the connection maybe lost.', this.key, this.options.maxIdleTime); + this.close(); + } + }, this.options.maxIdleTime); + } + + get isClosed() { + return this._closed; + } + + get lastActiveTime() { + return this._lastActiveTime; + } + + responseTimeout(request_seq) { + this.peddingReqs.delete(request_seq); + } + + writeResponse(seq, res) { + this.peddingReqs.delete(seq); + this._write(res); + } + + writeEvent(event) { + this._write(event); + } + + _dispatchMessage(msg) { + this._lastActiveTime = Date.now(); + + switch (msg.type) { + case 'request': + this.peddingReqs.set(msg.seq, msg); + this.emit('request', msg); + break; + case 'ping': // 心跳 + // this._write({ type: 'pong', request_seq: msg.seq }); + break; + case 'kill': // 主动 kill + this.emit('kill'); + break; + case 'cancel': // 取消某一个请求 + this._handleCancel(msg); + break; + default: + this._handleSocketError(new Error(`unknow message: ${JSON.stringify(msg)}`)); + break; + } + } + + _write(msg) { + if (this.isClosed) return; + + this.encoder.writeMessage(msg); + } + + _handleCancel(msg) { + const req = this.peddingReqs.get(msg.seq); + if (req) { + this.peddingReqs.delete(msg.seq); + this.emit('cancel', req.seq); + } + } + + _handleSocketError(err) { + if (err.code !== 'ECONNRESET') { + this.logger.warn('[TsConnection] error occured on socket: %s, errName: %s, errMsg: %s', this.key, err.name, err.message); + } + } + + _handleClose() { + this._closed = true; + this.peddingReqs.clear(); + clearInterval(this._timer); + this.emit('close'); + } + + close(err) { + if (this.isClosed) return Promise.resolve(); + + this.socket.destroy(err); + return this.await('close'); + } +} + +module.exports = TsConnection; diff --git a/lib/const.js b/lib/const.js new file mode 100644 index 0000000..d77ceac --- /dev/null +++ b/lib/const.js @@ -0,0 +1,3 @@ +'use strict'; + +exports.SOCKET_PORT = 16739; diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..90e02c3 --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,30 @@ +import * as stream from 'stream'; +import * as Proto from 'typescript/lib/protocol'; + +interface TsServerProcess { + readonly stdout: stream.Readable; + write(serverRequest: Proto.Request): void; + + on(name: 'exit', handler: (code: number | null) => void): void; + on(name: 'error', handler: (error: Error) => void): void; + + kill(): void; +} + +interface OngoingRequestCanceller { + tryCancelOngoingRequest(seq: number): boolean; +} + +export default class ClusterTsServerProcess implements TsServerProcess { + readonly requestCanceller: OngoingRequestCanceller; + readonly stdout: stream.Readable; + + constructor(options: any) {}; + + write(serverRequest: Proto.Request): void; + + on(name: 'exit', handler: (code: number | null) => void): void; + on(name: 'error', handler: (error: Error) => void): void; + + kill(): void; +}; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..a116e0d --- /dev/null +++ b/lib/index.js @@ -0,0 +1,97 @@ +'use strict'; + +const path = require('path'); +const Base = require('sdk-base'); +const cp = require('child_process'); +const awaitEvent = require('await-event'); +const TsProxyClient = require('./proxy_client'); +const { PassThrough, pipeline } = require('stream'); + +class ClusterTsServerProcess extends Base { + constructor(options = {}) { + super(Object.assign({}, options, { initMethod: '_init' })); + this.stdout = new PassThrough(); + + const { args } = this.options; + const index = args.indexOf('--cancellationPipeName'); + if (index >= 0) { + this._cancellationPipeName = args[index + 1].slice(0, -1); + } + + this._pendingReqs = []; + this._clientReady = false; + + this.requestCanceller = { + tryCancelOngoingRequest: seq => { + if (!this._cancellationPipeName || !this._client) return false; + + this._client.cancel(seq); + return true; + }, + }; + } + + async _init() { + await this._startServer(); + const { tsServerPath, args, tsServerForkOptions } = this.options; + this._client = new TsProxyClient({ tsServerPath, args, tsServerForkOptions }); + + this._client.on('exit', () => { + this.emit('exit'); + }); + this._client.on('error', err => { + this.emit('error', err); + }); + await this._client.ready(); + + pipeline(this._client.stdout, this.stdout, err => { + if (err) { + this.emit('error', err); + } + }); + + this._clientReady = true; + + let req = this._pendingReqs.shift(); + while (req) { + this.write(req); + req = this._pendingReqs.shift(); + } + } + + async _startServer() { + const { tsServerPath, args, tsServerForkOptions, logFile, logLevel } = this.options; + const proc = cp.fork(path.join(__dirname, 'start_server.js'), [ + '--args', + JSON.stringify({ tsServerPath, args, tsServerForkOptions, logFile, logLevel }), + ], { + detached: true, + stdio: 'inherit', + execArgv: [], + env: { + ...process.env, + NODE_OPTIONS: '--max_old_space_size=4096', + }, + }); + await awaitEvent(proc, 'message'); + proc.disconnect(); + proc.unref(); + } + + kill() { + if (!this._client) return; + + this._client.kill(); + } + + write(serverRequest) { + // 还没有 ready 前先存入数组,等 ready 后再一次性写入 + if (!this._clientReady) { + this._pendingReqs.push(serverRequest); + return; + } + this._client.write(serverRequest); + } +} + +module.exports = ClusterTsServerProcess; diff --git a/lib/protocol.js b/lib/protocol.js new file mode 100644 index 0000000..22f59e3 --- /dev/null +++ b/lib/protocol.js @@ -0,0 +1,90 @@ +'use strict'; + +const { Transform, Writable } = require('stream'); + +const BLANK = Buffer.from(' ', 'utf8')[0]; +const BACK_SLASH_R = Buffer.from('\r', 'utf8')[0]; +const BACK_SLASH_N = Buffer.from('\n', 'utf8')[0]; +const CONTENT_LENGTH = 'Content-Length: '; +const CONTENT_LENGTH_SIZE = Buffer.byteLength(CONTENT_LENGTH, 'utf8'); + +class TsServerEncoder extends Transform { + writeMessage(msg) { + this.write(JSON.stringify(msg)); + } + + _transform(chunk, encoding, callback) { + const size = Buffer.byteLength(chunk, 'utf8'); + callback(null, Buffer.from(`${CONTENT_LENGTH}${size}\r\n\r\n${chunk}\r\n`)); + } +} + +exports.TsServerEncoder = TsServerEncoder; + +class TsServerDecoder extends Writable { + _write(chunk, encoding, callback) { + // 合并 buf 中的数据 + this._buf = this._buf ? Buffer.concat([ this._buf, chunk ]) : chunk; + try { + let unfinish = false; + do { + unfinish = this._decode(); + } while (unfinish); + callback(); + } catch (err) { + err.name = 'TsServerDecodeError'; + err.data = this._buf ? this._buf.toString('base64') : ''; + callback(err); + } + } + + _tryReadContentLength() { + const bufLength = this._buf.length; + let cur = 0; + // we are utf8 encoding... + while (cur < bufLength && + (this._buf[cur] === BLANK || this._buf[cur] === BACK_SLASH_R || this._buf[cur] === BACK_SLASH_N) + ) { + cur++; + } + if (bufLength < cur + CONTENT_LENGTH_SIZE) { + return -1; + } + cur += CONTENT_LENGTH_SIZE; + const start = cur; + while (cur < bufLength && this._buf[cur] !== BACK_SLASH_R) { + cur++; + } + if (cur + 3 >= bufLength || this._buf[cur + 1] !== BACK_SLASH_N || this._buf[cur + 2] !== BACK_SLASH_R || this._buf[cur + 3] !== BACK_SLASH_N) { + return -1; + } + const data = this._buf.toString('utf8', start, cur); + const result = parseInt(data, 10); + if (cur + 4 + result > bufLength) { + return -1; + } + this._buf = this._buf.slice(cur + 4); + return result; + } + + _decode() { + const size = this._tryReadContentLength(); + + const bufLength = this._buf.length; + if (size === -1 || size > bufLength) { + return false; + } + const msg = this._buf.toString('utf8', 0, size); + this.emit('message', JSON.parse(msg)); + + const restLen = bufLength - size; + if (restLen) { + this._buf = this._buf.slice(size); + return true; + } + this._buf = null; + return false; + } +} + +exports.TsServerDecoder = TsServerDecoder; diff --git a/lib/proxy_client.js b/lib/proxy_client.js new file mode 100644 index 0000000..af8b20b --- /dev/null +++ b/lib/proxy_client.js @@ -0,0 +1,97 @@ +'use strict'; + +const net = require('net'); +const Base = require('sdk-base'); +const utils = require('./utils'); +const { pipeline } = require('stream'); +const sleep = require('mz-modules/sleep'); +const { TsServerEncoder } = require('./protocol'); + +const defaultOptions = { + logger: console, + maxIdleTime: 120000, // 120s 没有请求断开连接 +}; + +class TsProxyClient extends Base { + constructor(options = {}) { + super(Object.assign({}, defaultOptions, options)); + this._lastActiveTime = Date.now(); + this._connect(); + } + + get logger() { + return this.options.logger; + } + + get stdout() { + return this._socket; + } + + _connect() { + const { tsServerPath, args, tsServerForkOptions } = this.options; + const sockPath = utils.getSockPath({ + tsServerPath, + args, + tsServerForkOptions, + }); + this._timer = null; + this._isClosed = false; + this._socket = net.connect(sockPath); + this._socket.setNoDelay(true); + this._socket.setTimeout(5000, () => { + const err = new Error('TsServer connect timeout'); + this._socket.destroy(err); + }); + this._socket.once('close', () => { + this._isClosed = true; + clearInterval(this._timer); + this._timer = null; + this.emit('exit'); + }); + this._socket.on('error', err => { + this.logger.error(err); + }); + this._socket.once('connect', () => { + this._socket.setTimeout(0); + this.ready(true); + this._startPing(); + }); + + this._encoder = new TsServerEncoder(); + pipeline(this._encoder, this._socket, err => { + if (err) { + this.logger.error(err); + } + }); + } + + write(req) { + if (this._isClosed) return; + + this._lastActiveTime = Date.now(); + this._encoder.writeMessage(req); + } + + cancel(seq) { + this.write({ seq, type: 'cancel' }); + } + + kill() { + this.write({ type: 'kill' }); + } + + async _startPing() { + const timeout = this.options.maxIdleTime / 2; + + while (!this._isClosed) { + await sleep(timeout); + + const dur = Date.now() - this._lastActiveTime; + if (dur > timeout) { + this.write({ seq: 1, type: 'ping' }); + } + } + } +} + +module.exports = TsProxyClient; diff --git a/lib/proxy_server.js b/lib/proxy_server.js new file mode 100644 index 0000000..79629af --- /dev/null +++ b/lib/proxy_server.js @@ -0,0 +1,287 @@ +'use strict'; + +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const Base = require('sdk-base'); +const assert = require('assert'); +const utils = require('./utils'); +const cp = require('child_process'); +const { pipeline } = require('stream'); +const awaitFirst = require('await-first'); +const mkdirp = require('mz-modules/mkdirp'); +const rimraf = require('mz-modules/rimraf'); +const TsConnection = require('./connection'); +const { TsServerDecoder } = require('./protocol'); + +const defaultOptions = { + responseTimeout: 60000, // 1分钟没有返回,请求超时 + maxIdleTime: 120000, // 120s 没有请求断开连接 + logger: console, +}; + +class TsProxyServer extends Base { + constructor(options = {}) { + assert(options.tsServerPath, '[TsProxyServer] options.tsServerPath is required'); + + super(Object.assign({}, defaultOptions, options, { initMethod: '_init' })); + const { tsServerPath, args, tsServerForkOptions } = this.options; + this.sockPath = utils.getSockPath({ + tsServerPath, + args, + tsServerForkOptions, + }); + this.tsServerPath = options.tsServerPath; + this.args = options.args || []; + this.tsServerForkOptions = { + silent: true, + ...options.tsServerForkOptions, + }; + this.server = null; + this.tsProcess = null; + this.decoder = null; + this.conns = new Map(); + this.sequenceNumber = 0; + this.peddingRequests = new Map(); + this.closeTimer = null; + this.isClosed = false; + + // 日志里面标识用 + this.kind = args.includes('--syntaxOnly') ? 'syntax' : 'semantic'; + } + + get logger() { + return this.options.logger; + } + + async _init() { + // 先尝试连一下,如果连上说明服务已经存在,直接返回 + const connected = await this._tryConnect(); + if (connected) { + this.logger.info('[TsProxyServer] tsserver is already running, won\'t start again, tsServerPath: %s, args: %j, tsServerForkOptions: %j', + this.tsServerPath, this.args, this.tsServerForkOptions); + return; + } + this.server = await this._claimServer(); + if (!this.server) return; + + this.logger.info('[TsProxyServer] claim tsserver success, tsServerPath: %s, args: %j, tsServerForkOptions: %j', this.tsServerPath, this.args, this.tsServerForkOptions); + + this.server.once('close', async () => { + this.isClosed = true; + if (this.tsProcess) { + this.tsProcess.kill(); + } + await rimraf(this.sockPath); + this.logger.info('[TsProxyServer] server is closed.'); + this.emit('close'); + }); + const { tsServerPath, args, tsServerForkOptions } = this; + this.decoder = new TsServerDecoder(); + this.decoder.on('message', msg => { this._dispatchMessage(msg); }); + this.tsProcess = cp.fork(tsServerPath, args, tsServerForkOptions); + this.tsProcess.once('exit', (exitCode, signal) => { this._handleExit(exitCode, signal); }); + this.tsProcess.once('error', err => { this._handleProcessError(err); }); + + pipeline(this.tsProcess.stdout, this.decoder, err => { + if (err) { + this._handleProcessError(err); + } + }); + + // 是否支持 cancel 请求,若支持,则初始化目录 + const index = args.indexOf('--cancellationPipeName'); + if (index >= 0) { + this._cancellationPipeName = args[index + 1].slice(0, -1); + mkdirp.sync(path.dirname(this._cancellationPipeName)); + } + } + + async close() { + if (!this.server || this.isClosed) return; + + this.server.close(); + for (const conn of this.conns.values()) { + conn.close(); + } + await this.await('close'); + this.server = null; + } + + // code 有下面值 + // 00: 请求成功 + // 01: 请求失败 + // 02: 请求取消 + // 03: 请求超时 + _finishReq(seq, code) { + const handle = this.peddingRequests.get(seq); + if (!handle) { + this.logger.warn('[TsProxyServer] not found request:%s', seq); + return; + } + + this.peddingRequests.delete(seq); + + const { originSeq, meta, timer } = handle; + clearTimeout(timer); + + this.logger.info('[TsProxyServer] command trace: %s|%s|%s|%s|%s|%s', meta.command, seq, originSeq, code, meta.start, Date.now() - meta.start); + } + + _cancel(seq) { + this._finishReq(seq, '02'); + + // 如果传递了这个参数 + if (this._cancellationPipeName) { + fs.writeFile(this._cancellationPipeName + seq, '', () => { + // noop + }); + } + } + + _write(req, conn) { + if (!this.tsProcess.stdin) return; + + const originSeq = req.seq; + const newSeq = this.sequenceNumber++; + req.seq = newSeq; + const meta = { + start: Date.now(), + command: req.command, + }; + const timer = setTimeout(() => { + this._finishReq(newSeq, '03'); + conn.responseTimeout(originSeq); + }, this.options.responseTimeout); + this.peddingRequests.set(newSeq, { originSeq, conn, meta, timer }); + this.tsProcess.stdin.write(JSON.stringify(req) + '\r\n', 'utf8'); + + this.logger.debug('[TsProxyServer] write request: %j to server<%s>', req, this.kind); + } + + _dispatchResponse(seq, res) { + const handle = this.peddingRequests.get(seq); + if (!handle) { + this.logger.warn('[TsProxyServer] not found request:%s', seq); + return; + } + + let code = '00'; + if (res.type === 'response') { + res.request_seq = handle.originSeq; + code = res.success ? '00' : '01'; + } else { + res.body.request_seq = handle.originSeq; + } + handle.conn.writeResponse(handle.originSeq, res); + + this._finishReq(seq, code); + } + + _dispatchEvent(event) { + for (const conn of this.conns.values()) { + conn.writeEvent(event); + } + this.logger.debug('[TsProxyServer] received event: %j from tsserver<%s>', event, this.kind); + } + + _dispatchMessage(msg) { + this.logger.debug('[TsProxyServer] receive message: %j from tsserver<%s>', msg, this.kind); + switch (msg.type) { + case 'response': + this._dispatchResponse(msg.request_seq, msg); + break; + case 'event': + if (msg.event === 'requestCompleted') { + const seq = msg.body.request_seq; + this._dispatchResponse(seq, msg); + } else { + this._dispatchEvent(msg); + } + break; + default: + this.logger.error(new Error('received unknown message: ' + JSON.stringify(msg) + ' from tsserver<' + this.kind + '>')); + break; + } + } + + _handleExit(exitCode, signal) { + if (this.isClosed) return; + + this.logger.info('[TsProxyServer] tsserver process is exited with %s', exitCode != null ? `code: ${exitCode}` : `signal: ${signal}`); + this.close(); + } + + _handleProcessError(err) { + err.message = 'tsserver occurred an error: ' + err.message; + this.logger.error(err); + this.close(); + } + + _handleSocket(socket) { + clearTimeout(this.closeTimer); + const conn = new TsConnection({ + socket, + logger: this.options.logger, + maxIdleTime: this.options.maxIdleTime, + }); + this.conns.set(conn.key, conn); + conn.once('close', () => { + this.conns.delete(conn.key, conn); + // 如果最后一个 conn 断开以后,10s 内没有新的连接则停掉 server + if (this.conns.size === 0) { + this.closeTimer = setTimeout(() => { + this.close(); + }, 10000); + } + }); + conn.once('kill', () => { + this.logger.info('[TsProxyServer] received kill command, will close tsserver'); + this.close(); + }); + conn.on('request', req => { + this._write(req, conn); + }); + conn.on('cancel', seq => { + this._cancel(seq); + }); + this.logger.info('[TsProxyServer] an new socket:%s is connected, server kind: %s', conn.key, this.kind); + } + + async _tryConnect() { + if (fs.existsSync(this.sockPath)) { + const socket = net.connect(this.sockPath); + try { + await awaitFirst(socket, [ 'connect', 'error' ]); + socket.end(); + return true; + } catch (err) { + await rimraf(this.sockPath); + return false; + } + } + return false; + } + + async _claimServer() { + return new Promise(resolve => { + const server = net.createServer(); + server.on('connection', socket => { this._handleSocket(socket); }); + server.listen({ + path: this.sockPath, + // When exclusive is true, the handle is not shared, and attempted port sharing results in an error. + exclusive: true, + }); + server.once('error', err => { + this.logger.info('[TsProxyServer] claim tsserver failed, due to %s, tsServerPath: %s, args: %j, tsServerForkOptions: %j', + err.message, this.tsServerPath, this.args, this.tsServerForkOptions); + resolve(null); + }); + server.once('listening', () => { + resolve(server); + }); + }); + } +} + +module.exports = TsProxyServer; diff --git a/lib/start_server.js b/lib/start_server.js new file mode 100644 index 0000000..fe8d810 --- /dev/null +++ b/lib/start_server.js @@ -0,0 +1,45 @@ +'use strict'; + +const TsProxyServer = require('./proxy_server'); +const { Logger, FileTransport, ConsoleTransport } = require('egg-logger'); + +const options = JSON.parse(process.argv[3]); + +const logger = new Logger(); + +if (options.logFile) { + logger.set('file', new FileTransport({ + file: options.logFile, + level: options.logLevel || 'INFO', + })); +} else { + logger.set('console', new ConsoleTransport({ + level: options.logLevel || 'INFO', + })); +} + +options.logger = logger; +const server = new TsProxyServer(options); +server.once('close', () => { + logger.close(); +}); +server.ready() + .then(() => { + process.send('started'); + }); + +function handle() { + if (server) { + server.close(); + } +} + +process.on('exit', handle); +process.on('SIGINT', handle); +process.on('SIGTERM', handle); + +process.on('uncaughtException', (err, origin) => { + // TODO: + console.log(err, origin); + server.close(); +}); diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..d23f1db --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,34 @@ +'use strict'; + +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const mkdirp = require('mz-modules/mkdirp'); + +function md5(input) { + const result = crypto.createHash('md5').update(input).digest('base64'); + return result.replace(/=/gi, '*').replace(/\//ig, '_'); +} + +exports.md5 = md5; + +exports.getSockPath = options => { + mkdirp.sync(path.join(os.homedir(), '.cloudide/ts')); + const { tsServerPath, args, tsServerForkOptions } = options; + + const newArgs = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--cancellationPipeName' || args[i] === '--logFile') { + i = i + 1; + continue; + } else { + newArgs.push(args[i]); + } + } + + return path.join(os.homedir(), '.cloudide/ts', md5(JSON.stringify({ + tsServerPath, + args: newArgs, + tsServerForkOptions, + }))); +}; diff --git a/package.json b/package.json index b03349b..1399a74 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,15 @@ "name": "singleton-tsserver", "version": "1.0.0", "description": "singleton tsserver", - "main": "index.js", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib" + ], "scripts": { "autod": "autod", - "lint": "eslint . --ext .js", - "cov": "TEST_TIMEOUT=20000 egg-bin cov", + "lint": "eslint . --ext .js --fix", + "cov": "TEST_TIMEOUT=30000 egg-bin cov", "test": "npm run lint && npm run test-local", "test-local": "egg-bin test", "pkgfiles": "egg-bin pkgfiles --check", @@ -35,14 +39,24 @@ "version": "10, 12" }, "dependencies": { - + "await-event": "^2.1.0", + "await-first": "^1.0.0", + "egg-logger": "^2.4.1", + "mz-modules": "^2.1.0", + "sdk-base": "^3.6.0", + "uuid": "^7.0.1" }, "devDependencies": { "autod": "^3.1.0", + "await-event": "^2.1.0", "contributors": "^0.5.1", "egg-bin": "^4.14.1", "egg-ci": "^1.13.1", "eslint": "^6.8.0", - "eslint-config-egg": "^8.0.1" + "eslint-config-egg": "^8.0.1", + "mm": "^3.0.0", + "mz-modules": "^2.1.0", + "typescript": "^3.8.3", + "vscode": "^1.1.36" } } diff --git a/test/connection.test.js b/test/connection.test.js new file mode 100644 index 0000000..1271c96 --- /dev/null +++ b/test/connection.test.js @@ -0,0 +1,166 @@ +'use strict'; + +const mm = require('mm'); +const net = require('net'); +const assert = require('assert'); +const { pipeline } = require('stream'); +const sleep = require('mz-modules/sleep'); +const awaitEvent = require('await-event'); +const TsConnection = require('../lib/connection'); +const { TsServerEncoder, TsServerDecoder } = require('../lib/protocol'); + +const SOCKET_PORT = 16739; + +describe('test/connection.test.js', () => { + afterEach(mm.restore); + + it('should ok', async () => { + const server = net.createServer(); + server.listen(SOCKET_PORT); + await awaitEvent(server, 'listening'); + + const clientSocket = net.connect(SOCKET_PORT, '127.0.0.1'); + const socket = await awaitEvent(server, 'connection'); + const conn = new TsConnection({ + socket, + }); + assert(conn); + + const encoder = new TsServerEncoder(); + const decoder = new TsServerDecoder(); + pipeline(encoder, clientSocket, decoder, err => { + if (err) { + console.error(err); + } + }); + + const lastActiveTime = conn.lastActiveTime; + + encoder.writeMessage({ + seq: 1, + type: 'ping', + }); + + await sleep(100); + // const pong = await awaitEvent(decoder, 'message'); + // assert.deepEqual(pong, { type: 'pong', request_seq: 1 }); + + assert(conn.lastActiveTime > lastActiveTime); + + encoder.writeMessage({ + seq: 2, + type: 'request', + command: 'open', + arguments: { file: __filename }, + }); + + let req = await conn.await('request'); + assert(conn.peddingReqs && conn.peddingReqs.size === 1); + assert.deepEqual(conn.peddingReqs.get(2), req); + conn.responseTimeout(2); + + encoder.writeMessage({ + seq: 3, + type: 'request', + command: 'quickinfo', + arguments: { + file: __filename, + line: 3, + offset: 7, + }, + }); + + req = await conn.await('request'); + assert(conn.peddingReqs && conn.peddingReqs.size === 1); + assert.deepEqual(conn.peddingReqs.get(3), req); + + conn.writeResponse(3, { + type: 'response', + request_seq: 3, + success: false, + command: 'quickinfo', + message: 'unknow error', + }); + assert(conn.peddingReqs.size === 0); + + const res = await awaitEvent(decoder, 'message'); + assert.deepEqual(res, { + type: 'response', + request_seq: 3, + success: false, + command: 'quickinfo', + message: 'unknow error', + }); + + await conn.close(); + await conn.close(); + + server.close(); + await awaitEvent(server, 'close'); + }); + + it('should close after idle for a while', async () => { + const server = net.createServer(); + server.listen(SOCKET_PORT); + await awaitEvent(server, 'listening'); + + net.connect(SOCKET_PORT, '127.0.0.1'); + const socket = await awaitEvent(server, 'connection'); + const conn = new TsConnection({ + socket, + maxIdleTime: 1000, + }); + assert(conn); + assert(!conn.isClosed); + + await sleep(2000); + + assert(conn.isClosed); + + server.close(); + await awaitEvent(server, 'close'); + }); + + it('should log unknow message type', async () => { + const server = net.createServer(); + server.listen(SOCKET_PORT); + await awaitEvent(server, 'listening'); + + const clientSocket = net.connect(SOCKET_PORT, '127.0.0.1'); + const encoder = new TsServerEncoder(); + const decoder = new TsServerDecoder(); + pipeline(encoder, clientSocket, decoder, err => { + if (err) { + console.error(err); + } + }); + + const socket = await awaitEvent(server, 'connection'); + let warnArgs; + const conn = new TsConnection({ + socket, + logger: { + warn(...args) { + warnArgs = args; + }, + }, + }); + assert(conn); + + encoder.writeMessage({ + seq: 1, + type: 'xxx', + }); + + await sleep(200); + + assert(warnArgs); + + const errMsg = warnArgs[3]; + assert(errMsg === `unknow message: ${JSON.stringify({ seq: 1, type: 'xxx' })}`); + + conn.close(); + server.close(); + await awaitEvent(server, 'close'); + }); +}); diff --git a/test/fixtures/ts-app/index.ts b/test/fixtures/ts-app/index.ts new file mode 100644 index 0000000..de095e7 --- /dev/null +++ b/test/fixtures/ts-app/index.ts @@ -0,0 +1,2 @@ +let color: string = "blue"; +color = 'red'; diff --git a/test/fixtures/ts-app/package.json b/test/fixtures/ts-app/package.json new file mode 100644 index 0000000..5456ee2 --- /dev/null +++ b/test/fixtures/ts-app/package.json @@ -0,0 +1,3 @@ +{ + "name": "ts-app" +} diff --git a/test/fixtures/ts-app/tsconfig.json b/test/fixtures/ts-app/tsconfig.json new file mode 100644 index 0000000..da8eb3f --- /dev/null +++ b/test/fixtures/ts-app/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "strict": true, + "noImplicitAny": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "charset": "utf8", + "allowJs": false, + "pretty": true, + "noEmitOnError": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "strictPropertyInitialization": false, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "inlineSourceMap": true, + "importHelpers": true + }, + "exclude": [ + "app/public", + "app/web", + "app/views" + ] +} diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..5330b16 --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,143 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const uuid = require('uuid'); +const assert = require('assert'); +const { pipeline } = require('stream'); +const sleep = require('mz-modules/sleep'); +const awaitEvent = require('await-event'); +const rimraf = require('mz-modules/rimraf'); +const ClusterTsServerProcess = require('../lib'); +const { TsServerDecoder } = require('../lib/protocol'); + +const tsServerPath = path.join(path.dirname(require.resolve('typescript')), 'tsserver.js'); +const tsServerForkOptions = { + silent: true, +}; + +const logFile = path.join(__dirname, '.tmp', 'logs/tsserver.log'); + +describe('test/index.test.js', () => { + before(async () => { + await rimraf(path.join(__dirname, '.tmp')); + }); + + it('should work', async () => { + const options = { + tsServerPath, + args: [ + '--useInferredProjectPerProjectRoot', + '--enableTelemetry', + '--cancellationPipeName', + path.join(__dirname, '.tmp/tscancellation', uuid.v4() + '.tmp*'), + '--locale', 'zh-CN', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', + ], + tsServerForkOptions, + logFile, + logLevel: 'DEBUG', + }; + const proc = new ClusterTsServerProcess(options); + assert(proc && proc.requestCanceller); + + const decoder = new TsServerDecoder(); + + pipeline(proc.stdout, decoder, err => { + if (err) { + console.error(err); + } + }); + + proc.write({ + seq: 0, + type: 'request', + command: 'configure', + arguments: { + hostInfo: 'vscode', + preferences: { + providePrefixAndSuffixTextForRename: true, + allowRenameOfImportPath: true, + }, + }, + }); + + proc.write({ + seq: 1, + type: 'request', + command: 'compilerOptionsForInferredProjects', + arguments: { + options: { + module: 'commonjs', + target: 'es2016', + jsx: 'preserve', + allowJs: true, + allowSyntheticDefaultImports: true, + allowNonTsExtensions: true, + }, + }, + }); + + proc.write({ + seq: 2, + type: 'request', + command: 'updateOpen', + arguments: { + changedFiles: [], + closedFiles: [], + openFiles: [{ + file: path.join(__dirname, 'fixtures/ts-app/index.ts'), + fileContent: fs.readFileSync(path.join(__dirname, 'fixtures/ts-app/index.ts'), 'utf8'), + scriptKindName: 'TS', + projectRootPath: path.join(__dirname, 'fixtures/ts-app'), + }], + }, + }); + + proc.write({ + seq: 3, + type: 'request', + command: 'geterr', + arguments: { + delay: 0, + files: [ path.join(__dirname, 'fixtures/ts-app/index.ts') ], + }, + }); + + proc.write({ + seq: 4, + type: 'request', + command: 'getSupportedCodeFixes', + arguments: null, + }); + + + const reqSet = new Set([ 0, 1, 2, 3, 4 ]); + + decoder.on('message', msg => { + if (msg.type === 'response') { + console.log('response --------------->', msg); + assert(reqSet.has(msg.request_seq)); + reqSet.delete(msg.request_seq); + } else if (msg.type === 'event' && msg.event === 'requestCompleted') { + console.log('requestCompleted -------------->', msg); + assert(reqSet.has(msg.body.request_seq)); + reqSet.delete(msg.body.request_seq); + } + if (reqSet.size === 0) { + decoder.emit('finished'); + } + }); + + await awaitEvent(decoder, 'finished'); + + console.log('----------------> kill'); + proc.kill(); + + await proc.await('exit'); + await sleep(100); + + assert(fs.existsSync(logFile)); + }); +}); diff --git a/test/proxy.test.js b/test/proxy.test.js new file mode 100644 index 0000000..1a4859b --- /dev/null +++ b/test/proxy.test.js @@ -0,0 +1,261 @@ +'use strict'; + +const path = require('path'); +const uuid = require('uuid'); +const assert = require('assert'); +const sleep = require('mz-modules/sleep'); +const awaitEvent = require('await-event'); +const rimraf = require('mz-modules/rimraf'); +const TsProxyClient = require('../lib/proxy_client'); +const TsProxyServer = require('../lib/proxy_server'); + +const tsServerPath = path.join(path.dirname(require.resolve('typescript')), 'tsserver.js'); +const args = [ + '--useInferredProjectPerProjectRoot', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', +]; +const tsServerForkOptions = { + silent: true, +}; + +describe('test/proxy.test.js', () => { + before(async () => { + await rimraf(path.join(__dirname, '.tmp')); + }); + after(async () => { + await rimraf(path.join(__dirname, '.tmp')); + }); + + it('should init one tsserver', async () => { + const server = new TsProxyServer({ + tsServerPath, + args, + tsServerForkOptions, + }); + await server.ready(); + + const server2 = new TsProxyServer({ + tsServerPath, + args, + tsServerForkOptions, + }); + await server2.ready(); + + await server2.close(); + await server.close(); + }); + + it('should ok', async () => { + const server = new TsProxyServer({ + tsServerPath, + args, + tsServerForkOptions, + }); + await server.ready(); + + const client = new TsProxyClient({ + tsServerPath, + args, + tsServerForkOptions, + }); + client.write({ + seq: 1, + type: 'request', + command: 'open', + arguments: { file: path.join(__dirname, '../lib/protocol.js') }, + }); + + client.write({ + seq: 2, + type: 'request', + command: 'quickinfo', + arguments: { + file: path.join(__dirname, '../lib/protocol.js'), + line: 5, + offset: 7, + }, + }); + + const data = await awaitEvent(client.stdout, 'data'); + console.log(data.toString()); + + await server.close(); + }); + + it('should kill server by client', async () => { + const server = new TsProxyServer({ + tsServerPath, + args, + tsServerForkOptions, + }); + await server.ready(); + + const client = new TsProxyClient({ + tsServerPath, + args, + tsServerForkOptions, + }); + + client.write({ + seq: 1, + type: 'kill', + }); + + await server.await('close'); + }); + + it('should dispatch event to all clients', async () => { + const server = new TsProxyServer({ + tsServerPath, + args, + tsServerForkOptions, + }); + await server.ready(); + + const client1 = new TsProxyClient({ + tsServerPath, + args, + tsServerForkOptions, + }); + const client2 = new TsProxyClient({ + tsServerPath, + args, + tsServerForkOptions, + }); + + client1.write({ + seq: 1, + type: 'request', + command: 'open', + arguments: { file: path.join(__dirname, 'fixtures/ts-app/index.ts') }, + }); + + const [ data1, data2 ] = await Promise.all([ + awaitEvent(client1.stdout, 'data'), + awaitEvent(client2.stdout, 'data'), + ]); + console.log(data1.toString()); + console.log(data2.toString()); + + await server.close(); + }); + + it('should auto clear timeout request', async () => { + const server = new TsProxyServer({ + tsServerPath, + args, + tsServerForkOptions, + responseTimeout: 1000, + }); + await server.ready(); + + const client = new TsProxyClient({ + tsServerPath, + args, + tsServerForkOptions, + }); + + client.write({ + seq: 1, + type: 'request', + command: 'open', + arguments: { file: path.join(__dirname, 'fixtures/ts-app/index.ts') }, + }); + + await sleep(100); + + assert(server.peddingRequests && server.peddingRequests.size === 1); + + await sleep(1000); + + assert(server.peddingRequests && server.peddingRequests.size === 0); + + await server.close(); + }); + + it('should cancel request', async () => { + const newArgs = args.concat([ + '--cancellationPipeName', + path.join(__dirname, '.tmp/tscancellation', uuid.v4() + '.tmp*'), + ]); + const server = new TsProxyServer({ + tsServerPath, + args: newArgs, + tsServerForkOptions, + responseTimeout: 3000, + }); + await server.ready(); + + const client = new TsProxyClient({ + tsServerPath, + args: newArgs, + tsServerForkOptions, + }); + client.stdout.on('data', data => { + console.log(data.toString()); + }); + + client.write({ + seq: 1, + type: 'request', + command: 'open', + arguments: { file: path.join(__dirname, 'fixtures/ts-app/index.ts') }, + }); + client.write({ + seq: 2, + type: 'request', + command: 'quickinfo', + arguments: { + file: path.join(__dirname, 'fixtures/ts-app/index.ts'), + line: 2, + offset: 1, + }, + }); + + await sleep(100); + assert(server.peddingRequests && server.peddingRequests.size === 2); + + client.cancel(2); + await sleep(100); + assert(server.peddingRequests && server.peddingRequests.size === 1); + + await sleep(5000); + assert(server.peddingRequests && server.peddingRequests.size === 0); + + await server.close(); + }); + + it('should send ping if idle for a while', async () => { + const server = new TsProxyServer({ + tsServerPath, + args, + tsServerForkOptions, + }); + await server.ready(); + + const client = new TsProxyClient({ + tsServerPath, + args, + tsServerForkOptions, + maxIdleTime: 6000, + }); + await client.ready(); + + client.write({ + seq: 1, + type: 'request', + command: 'open', + arguments: { file: path.join(__dirname, 'fixtures/ts-app/index.ts') }, + }); + + const firstTime = client._lastActiveTime; + + await sleep(3100); + + const secondTime = client._lastActiveTime; + assert(secondTime > firstTime); + + await server.close(); + }); +}); diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..4a2b576 --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,59 @@ +'use strict'; + +const path = require('path'); +const assert = require('assert'); +const utils = require('../lib/utils'); + +describe('test/utils.test.js', () => { + it('should getSockPath ok', () => { + const tsServerPath = path.join(path.dirname(require.resolve('typescript')), 'tsserver.js'); + const args1 = [ + '--syntaxOnly', + '--useInferredProjectPerProjectRoot', + '--disableAutomaticTypingAcquisition', + '--noGetErrOnBackgroundUpdate', + '--cancellationPipeName', + '/var/folders/q4/4nwl16wn32ndm69rzh1zyvhh0000gn/T/vscode-typescript501/f78125e6de579d114c4b/tscancellation-02c830b011e535ace5b8.tmp*', + '--globalPlugins', + 'typescript-vscode-sh-plugin,typescript-tslint-plugin,@vsintellicode/typescript-intellicode-plugin', + '--pluginProbeLocations', + '/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/typescript-language-features,/Users/gaoxiaochen/.vscode/extensions/ms-vscode.vscode-typescript-tslint-plugin-1.2.3,/Users/gaoxiaochen/.vscode/extensions/visualstudioexptteam.vscodeintellicode-1.2.5', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', + '--logFile', + '/home/admin/logs/xxx.log', + ]; + const tsServerForkOptions = { + silent: true, + }; + const p1 = utils.getSockPath({ + tsServerPath, + args: args1, + tsServerForkOptions, + }); + console.log(p1); + + const args2 = [ + '--syntaxOnly', + '--useInferredProjectPerProjectRoot', + '--disableAutomaticTypingAcquisition', + '--noGetErrOnBackgroundUpdate', + '--cancellationPipeName', + '/var/folders/q4/4nwl16wn32ndm69rzh1zyvhh0000gn/T/vscode-typescript501/f78125e6de579d114c4b/tscancellation-03a6eacfe6d7a61df990.tmp*', + '--globalPlugins', + 'typescript-vscode-sh-plugin,typescript-tslint-plugin,@vsintellicode/typescript-intellicode-plugin', + '--pluginProbeLocations', + '/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/typescript-language-features,/Users/gaoxiaochen/.vscode/extensions/ms-vscode.vscode-typescript-tslint-plugin-1.2.3,/Users/gaoxiaochen/.vscode/extensions/visualstudioexptteam.vscodeintellicode-1.2.5', + '--noGetErrOnBackgroundUpdate', + '--validateDefaultNpmLocation', + ]; + const p2 = utils.getSockPath({ + tsServerPath, + args: args2, + tsServerForkOptions, + }); + console.log(p2); + + assert(p1 === p2); + }); +});