29 |
30 | // Aliases because vscode-jsonrpc's interfaces are weird.
31 | export type RequestType = RequestType1
32 | export type NotificationType
= NotificationType1
33 |
34 | export interface Dispatcher {
35 | observeNotification
(type: NotificationType
): Observable
36 | setRequestHandler
(type: RequestType
, handler: RequestHandler
): void
37 | dispose(): void
38 | }
39 |
40 | export const createRequestDurationMetric = () =>
41 | new prometheus.Histogram({
42 | name: 'jsonrpc_request_duration_seconds',
43 | help: 'The JSON RPC request latencies in seconds',
44 | labelNames: ['success', 'method'],
45 | buckets: [0.1, 0.2, 0.5, 0.8, 1, 1.5, 2, 5, 10, 15, 20, 30],
46 | })
47 |
48 | /**
49 | * Alternative dispatcher to vscode-jsonrpc that supports OpenTracing and Observables
50 | */
51 | export function createDispatcher(
52 | client: Connection,
53 | {
54 | tags,
55 | tracer,
56 | logger,
57 | requestDurationMetric,
58 | }: {
59 | /** Tags to set on every Span */
60 | tags: Record
61 | tracer: Tracer
62 | logger: Logger
63 | /**
64 | * Optional prometheus metric that request durations will be logged to.
65 | * Must have labels `success` and `method`.
66 | *
67 | * @see createRequestDurationMetric
68 | */
69 | requestDurationMetric?: prometheus.Histogram
70 | }
71 | ): Dispatcher {
72 | const cancellationTokenSources = new Map()
73 | const handlers = new Map>()
74 | const notifications = new Subject()
75 |
76 | client.reader.listen(async message => {
77 | if (isNotificationMessage(message)) {
78 | if (message.method === '$/cancelRequest') {
79 | // Cancel the handling of a different request
80 | const canellationTokenSource = cancellationTokenSources.get(message.params.id)
81 | if (canellationTokenSource) {
82 | canellationTokenSource.cancel()
83 | }
84 | } else {
85 | notifications.next(message)
86 | }
87 | } else if (isRequestMessage(message)) {
88 | const stopTimer = requestDurationMetric && requestDurationMetric.startTimer()
89 | let success: boolean
90 | const childOf = tracer.extract(FORMAT_TEXT_MAP, message.params) || undefined
91 | const span = tracer.startSpan('Handle ' + message.method, { tags, childOf })
92 | span.setTag('method', message.method)
93 | if (isRequestMessage(message)) {
94 | span.setTag('id', message.id)
95 | }
96 | const cancellationTokenSource = new CancellationTokenSource()
97 | cancellationTokenSources.set(message.id, cancellationTokenSource)
98 | const token = cancellationTokenSource.token
99 | let response: ResponseMessage | undefined
100 | try {
101 | const handler = handlers.get(message.method)
102 | if (!handler) {
103 | throw Object.assign(new Error('No handler for method ' + message.method), {
104 | code: ErrorCodes.MethodNotFound,
105 | })
106 | }
107 | const result = await Promise.resolve(handler(message.params, token, span))
108 | success = true
109 | response = {
110 | jsonrpc: '2.0',
111 | id: message.id,
112 | result,
113 | }
114 | } catch (err) {
115 | span.setTag(ERROR, true)
116 | success = false
117 | logErrorEvent(span, err)
118 |
119 | if (!isAbortError(err)) {
120 | logger.error('Error handling message\n', message, '\n', err)
121 | }
122 | if (isRequestMessage(message)) {
123 | const code = isAbortError(err)
124 | ? ErrorCodes.RequestCancelled
125 | : typeof err.code === 'number'
126 | ? err.code
127 | : ErrorCodes.UnknownErrorCode
128 | response = {
129 | jsonrpc: '2.0',
130 | id: message.id,
131 | error: {
132 | message: err.message,
133 | code,
134 | data: {
135 | stack: err.stack,
136 | ...err,
137 | },
138 | },
139 | }
140 | }
141 | } finally {
142 | cancellationTokenSources.delete(message.id)
143 | span.finish()
144 | }
145 | if (response) {
146 | client.writer.write(response)
147 | }
148 | if (stopTimer) {
149 | stopTimer({ success: success + '', method: message.method })
150 | }
151 | }
152 | })
153 |
154 | return {
155 | observeNotification(type: NotificationType
): Observable
{
156 | const method = type.method
157 | return notifications.pipe(
158 | filter(message => message.method === method),
159 | map(message => message.params)
160 | )
161 | },
162 | setRequestHandler
(type: RequestType
, handler: RequestHandler
): void {
163 | handlers.set(type.method, handler)
164 | },
165 | dispose(): void {
166 | for (const cancellationTokenSource of cancellationTokenSources.values()) {
167 | tryCancel(cancellationTokenSource)
168 | }
169 | },
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/disposable.ts:
--------------------------------------------------------------------------------
1 | import { Unsubscribable } from 'rxjs'
2 | import { Logger } from './logging'
3 |
4 | export interface Disposable {
5 | dispose(): void
6 | }
7 |
8 | export interface AsyncDisposable {
9 | disposeAsync(): Promise
10 | }
11 | export const isAsyncDisposable = (val: any): val is AsyncDisposable =>
12 | typeof val === 'object' && val !== null && typeof val.disposeAsync === 'function'
13 |
14 | export const isUnsubscribable = (val: any): val is Unsubscribable =>
15 | typeof val === 'object' && val !== null && typeof val.unsubscribe === 'function'
16 |
17 | /**
18 | * Disposes all provided Disposables, sequentially, in order.
19 | * Disposal is best-effort, meaning if any Disposable fails to dispose, the error is logged and the function proceeds to the next one.
20 | *
21 | * @throws never
22 | */
23 | export function disposeAll(disposables: Iterable, logger: Logger = console): void {
24 | for (const disposable of disposables) {
25 | try {
26 | if (isUnsubscribable(disposable)) {
27 | disposable.unsubscribe()
28 | } else {
29 | disposable.dispose()
30 | }
31 | } catch (err) {
32 | logger.error('Error disposing', disposable, err)
33 | }
34 | }
35 | }
36 |
37 | /**
38 | * Disposes all provided Disposables, sequentially, in order.
39 | * Disposal is best-effort, meaning if any Disposable fails to dispose, the error is logged and the function proceeds to the next one.
40 | * An AsyncDisposable is given 20 seconds to dispose, otherwise the function proceeds to the next disposable.
41 | *
42 | * @throws never
43 | */
44 | export async function disposeAllAsync(
45 | disposables: Iterable,
46 | { logger = console, timeout = 20000 }: { logger?: Logger; timeout?: number } = {}
47 | ): Promise {
48 | for (const disposable of disposables) {
49 | try {
50 | if (isAsyncDisposable(disposable)) {
51 | await Promise.race([
52 | disposable.disposeAsync(),
53 | new Promise((_, reject) =>
54 | setTimeout(
55 | () => reject(new Error(`AsyncDisposable did not dispose within ${timeout}ms`)),
56 | timeout
57 | )
58 | ),
59 | ])
60 | } else if (isUnsubscribable(disposable)) {
61 | disposable.unsubscribe()
62 | } else {
63 | disposable.dispose()
64 | }
65 | } catch (err) {
66 | logger.error('Error disposing', disposable, err)
67 | }
68 | }
69 | }
70 |
71 | /**
72 | * Converts an RxJS Subscription to a Disposable.
73 | */
74 | export const subscriptionToDisposable = (subscription: Unsubscribable): Disposable => ({
75 | dispose: () => subscription.unsubscribe(),
76 | })
77 |
--------------------------------------------------------------------------------
/src/graphql.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import gql from 'tagged-template-noop'
3 |
4 | interface Options {
5 | instanceUrl: URL
6 | accessToken?: string
7 | }
8 |
9 | /**
10 | * Does a GraphQL request to the Sourcegraph GraphQL API
11 | *
12 | * @param query The GraphQL request (query or mutation)
13 | * @param variables A key/value object with variable values
14 | */
15 | export async function requestGraphQL(
16 | query: string,
17 | variables: any = {},
18 | { instanceUrl, accessToken }: Options
19 | ): Promise<{ data?: any; errors?: { message: string; path: string }[] }> {
20 | const headers: Record = {
21 | Accept: 'application/json',
22 | 'Content-Type': 'application/json',
23 | 'User-Agent': 'TypeScript language server',
24 | }
25 | if (accessToken) {
26 | headers.Authorization = 'token ' + accessToken
27 | }
28 | const response = await got.post(new URL('/.api/graphql', instanceUrl).href, {
29 | headers,
30 | body: JSON.stringify({ query, variables }),
31 | })
32 | return JSON.parse(response.body)
33 | }
34 |
35 | /**
36 | * Uses the Sourcegraph GraphQL API to resolve a git clone URL to a Sourcegraph repository name.
37 | *
38 | * @param cloneUrl A git clone URL
39 | * @return The Sourcegraph repository name (can be used to construct raw API URLs)
40 | */
41 | export async function resolveRepository(cloneUrl: string, options: Options): Promise {
42 | const { data, errors } = await requestGraphQL(
43 | gql`
44 | query($cloneUrl: String!) {
45 | repository(cloneURL: $cloneUrl) {
46 | name
47 | }
48 | }
49 | `,
50 | { cloneUrl },
51 | options
52 | )
53 | if (errors && errors.length > 0) {
54 | throw new Error('GraphQL Error:' + errors.map(e => e.message).join('\n'))
55 | }
56 | if (!data.repository) {
57 | throw new Error(`No repository found for clone URL ${cloneUrl} on instance ${options.instanceUrl}`)
58 | }
59 | return data.repository.name
60 | }
61 |
--------------------------------------------------------------------------------
/src/ix.ts:
--------------------------------------------------------------------------------
1 | import { AsyncIterableX, from } from 'ix/asynciterable'
2 | import { MergeAsyncIterable } from 'ix/asynciterable/merge'
3 | import { flatMap, share } from 'ix/asynciterable/operators'
4 |
5 | /**
6 | * Flatmaps the source iterable with `selector`, `concurrency` times at a time.
7 | */
8 | export const flatMapConcurrent = (
9 | source: AsyncIterable,
10 | concurrency: number,
11 | selector: (value: T) => AsyncIterable
12 | ): AsyncIterableX =>
13 | new MergeAsyncIterable(new Array>(concurrency).fill(from(source).pipe(share(), flatMap(selector))))
14 |
--------------------------------------------------------------------------------
/src/language-server.ts:
--------------------------------------------------------------------------------
1 | import { fork } from 'child_process'
2 | import { writeFile } from 'mz/fs'
3 | import { Tracer } from 'opentracing'
4 | import { SPAN_KIND, SPAN_KIND_RPC_SERVER } from 'opentracing/lib/ext/tags'
5 | import * as path from 'path'
6 | import { fromEvent, Observable } from 'rxjs'
7 | import { Tail } from 'tail'
8 | import {
9 | createMessageConnection,
10 | Disposable,
11 | IPCMessageReader,
12 | IPCMessageWriter,
13 | MessageConnection,
14 | } from 'vscode-jsonrpc'
15 | import { LogMessageNotification } from 'vscode-languageserver-protocol'
16 | import { Settings } from './config'
17 | import { createDispatcher, Dispatcher } from './dispatcher'
18 | import { disposeAll, subscriptionToDisposable } from './disposable'
19 | import { LOG_LEVEL_TO_LSP, Logger, LSP_TO_LOG_LEVEL, PrefixedLogger } from './logging'
20 |
21 | const TYPESCRIPT_LANGSERVER_JS_BIN = path.resolve(
22 | __dirname,
23 | '..',
24 | 'node_modules',
25 | '@sourcegraph',
26 | 'typescript-language-server',
27 | 'lib',
28 | 'cli.js'
29 | )
30 |
31 | export interface LanguageServer extends Disposable {
32 | connection: MessageConnection
33 | dispatcher: Dispatcher
34 | /** Error events from the process (e.g. spawning failed) */
35 | errors: Observable
36 | }
37 |
38 | export async function spawnLanguageServer({
39 | tempDir,
40 | tsserverCacheDir,
41 | configuration,
42 | connectionId,
43 | logger,
44 | tracer,
45 | }: {
46 | tempDir: string
47 | tsserverCacheDir: string
48 | configuration: Settings
49 | connectionId: string
50 | logger: Logger
51 | tracer: Tracer
52 | }): Promise {
53 | const disposables = new Set()
54 | const args: string[] = [
55 | '--node-ipc',
56 | // Use local tsserver instead of the tsserver of the repo for security reasons
57 | '--tsserver-path=' + path.join(__dirname, '..', 'node_modules', 'typescript', 'bin', 'tsserver'),
58 | ]
59 | if (configuration['typescript.langserver.log']) {
60 | args.push('--log-level=' + LOG_LEVEL_TO_LSP[configuration['typescript.langserver.log'] || 'log'])
61 | }
62 | if (configuration['typescript.tsserver.log']) {
63 | // Prepare tsserver log file
64 | const tsserverLogFile = path.resolve(tempDir, 'tsserver.log')
65 | await writeFile(tsserverLogFile, '') // File needs to exist or else Tail will error
66 | const tsserverLogger = new PrefixedLogger(logger, 'tsserver')
67 | // Set up a tail -f on the tsserver logfile and forward the logs to the logger
68 | const tsserverTail = new Tail(tsserverLogFile, { follow: true, fromBeginning: true, useWatchFile: true })
69 | disposables.add({ dispose: () => tsserverTail.unwatch() })
70 | tsserverTail.on('line', line => tsserverLogger.log(line + ''))
71 | tsserverTail.on('error', err => logger.error('Error tailing tsserver logs', err))
72 | args.push('--tsserver-log-file', tsserverLogFile)
73 | args.push('--tsserver-log-verbosity', configuration['typescript.tsserver.log'] || 'verbose')
74 | }
75 | logger.log('Spawning language server')
76 | const serverProcess = fork(TYPESCRIPT_LANGSERVER_JS_BIN, args, {
77 | env: {
78 | ...process.env,
79 | XDG_CACHE_HOME: tsserverCacheDir,
80 | },
81 | stdio: ['ipc', 'inherit'],
82 | execArgv: [],
83 | })
84 | disposables.add({ dispose: () => serverProcess.kill() })
85 | // Log language server STDERR output
86 | const languageServerLogger = new PrefixedLogger(logger, 'langserver')
87 | serverProcess.stderr!.on('data', chunk => languageServerLogger.log(chunk + ''))
88 | const languageServerReader = new IPCMessageReader(serverProcess)
89 | const languageServerWriter = new IPCMessageWriter(serverProcess)
90 | disposables.add(languageServerWriter)
91 | const connection = createMessageConnection(languageServerReader, languageServerWriter, logger)
92 | disposables.add(connection)
93 | connection.listen()
94 |
95 | // Forward log messages from the language server to the browser
96 | const dispatcher = createDispatcher(
97 | {
98 | reader: languageServerReader,
99 | writer: languageServerWriter,
100 | },
101 | {
102 | tracer,
103 | logger,
104 | tags: {
105 | connectionId,
106 | [SPAN_KIND]: SPAN_KIND_RPC_SERVER,
107 | },
108 | }
109 | )
110 | disposables.add(
111 | subscriptionToDisposable(
112 | dispatcher.observeNotification(LogMessageNotification.type).subscribe(params => {
113 | const type = LSP_TO_LOG_LEVEL[params.type]
114 | languageServerLogger[type](params.message)
115 | })
116 | )
117 | )
118 | return {
119 | connection,
120 | dispatcher,
121 | errors: fromEvent(serverProcess, 'error'),
122 | dispose: () => disposeAll(disposables),
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/logging.ts:
--------------------------------------------------------------------------------
1 | import { inspect } from 'util'
2 | import { MessageConnection } from 'vscode-jsonrpc'
3 | import { LogMessageNotification, MessageType } from 'vscode-languageserver-protocol'
4 |
5 | export type LogLevel = 'error' | 'warn' | 'info' | 'log'
6 | export type Logger = Record void>
7 |
8 | export abstract class AbstractLogger implements Logger {
9 | protected abstract logType(type: LogLevel, values: unknown[]): void
10 |
11 | public log(...values: unknown[]): void {
12 | this.logType('log', values)
13 | }
14 |
15 | public info(...values: unknown[]): void {
16 | this.logType('info', values)
17 | }
18 |
19 | public warn(...values: unknown[]): void {
20 | this.logType('warn', values)
21 | }
22 |
23 | public error(...values: unknown[]): void {
24 | this.logType('error', values)
25 | }
26 | }
27 |
28 | /**
29 | * Logger implementation that does nothing
30 | */
31 | export class NoopLogger extends AbstractLogger {
32 | protected logType(): void {
33 | // noop
34 | }
35 | }
36 |
37 | export const LOG_LEVEL_TO_LSP: Record = {
38 | log: MessageType.Log,
39 | info: MessageType.Info,
40 | warn: MessageType.Warning,
41 | error: MessageType.Error,
42 | }
43 |
44 | export const LSP_TO_LOG_LEVEL: Record = {
45 | [MessageType.Log]: 'log',
46 | [MessageType.Info]: 'info',
47 | [MessageType.Warning]: 'warn',
48 | [MessageType.Error]: 'error',
49 | }
50 |
51 | /**
52 | * Formats values to a message by pretty-printing objects
53 | */
54 | export const format = (value: unknown): string =>
55 | typeof value === 'string' ? value : inspect(value, { depth: Infinity })
56 |
57 | /**
58 | * Removes auth info from URLs
59 | */
60 | export const redact = (message: string): string => message.replace(/(https?:\/\/)[^@\/]+@([^\s$]+)/g, '$1$2')
61 |
62 | /**
63 | * Logger that formats the logged values and removes any auth info in URLs.
64 | */
65 | export class RedactingLogger extends AbstractLogger {
66 | constructor(private logger: Logger) {
67 | super()
68 | }
69 |
70 | protected logType(type: LogLevel, values: unknown[]): void {
71 | // TODO ideally this would not format the value to a string before redacting,
72 | // because that prevents expanding objects in devtools
73 | this.logger[type](...values.map(value => redact(format(value))))
74 | }
75 | }
76 |
77 | export class PrefixedLogger extends AbstractLogger {
78 | constructor(private logger: Logger, private prefix: string) {
79 | super()
80 | }
81 |
82 | protected logType(type: LogLevel, values: unknown[]): void {
83 | this.logger[type](`[${this.prefix}]`, ...values)
84 | }
85 | }
86 |
87 | export class MultiLogger extends AbstractLogger {
88 | constructor(private loggers: Logger[]) {
89 | super()
90 | }
91 |
92 | protected logType(type: LogLevel, values: unknown[]): void {
93 | for (const logger of this.loggers) {
94 | logger[type](...values)
95 | }
96 | }
97 | }
98 |
99 | /**
100 | * A logger implementation that sends window/logMessage notifications to an LSP client
101 | */
102 | export class LSPLogger extends AbstractLogger {
103 | /**
104 | * @param client The client to send window/logMessage notifications to
105 | */
106 | constructor(private client: MessageConnection) {
107 | super()
108 | }
109 |
110 | protected logType(type: LogLevel, values: unknown[]): void {
111 | try {
112 | this.client.sendNotification(LogMessageNotification.type, {
113 | type: LOG_LEVEL_TO_LSP[type],
114 | message: values.map(format).join(' '),
115 | })
116 | } catch (err) {
117 | // ignore
118 | }
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/progress.ts:
--------------------------------------------------------------------------------
1 | import { isEqual } from 'lodash'
2 | import { Observer, Subject } from 'rxjs'
3 | import { distinctUntilChanged, scan, takeWhile, throttleTime } from 'rxjs/operators'
4 | import { MessageConnection } from 'vscode-jsonrpc'
5 | import { MessageType, ShowMessageNotification } from 'vscode-languageserver-protocol'
6 | import { Logger } from './logging'
7 | import { WindowProgressNotification } from './protocol.progress.proposed'
8 | import { tryLogError } from './util'
9 |
10 | export interface Progress {
11 | /** Integer from 0 to 100 */
12 | percentage?: number
13 | message?: string
14 | }
15 |
16 | /**
17 | * A ProgressReporter is an Observer for progress reporting.
18 | * Calling `next()` or `complete()` never throws.
19 | * `complete()` is idempotent.
20 | * Emitting a percentage of `100` has the same effect as calling `complete()`.
21 | */
22 | export type ProgressReporter = Observer