├── .buildkite └── hooks │ └── pre-command ├── .dockerignore ├── .editorconfig ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── pr-auditor.yml ├── .gitignore ├── .prettierignore ├── .tool-versions ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .yarnrc ├── Dockerfile ├── LICENSE ├── README.md ├── buildkite.yml ├── ci-checkov.sh ├── deploy.sh ├── package.json ├── prettier.config.js ├── renovate.json ├── src ├── cancellation.ts ├── config.ts ├── dependencies.ts ├── dispatcher.ts ├── disposable.ts ├── graphql.ts ├── ix.ts ├── language-server.ts ├── logging.ts ├── progress.ts ├── protocol.progress.proposed.ts ├── resources.ts ├── server.ts ├── tracing.ts ├── tsconfig.ts ├── types │ ├── node │ │ └── index.d.ts │ └── npm-registry-fetch │ │ └── index.d.ts ├── uri.ts ├── util.ts └── yarn.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.buildkite/hooks/pre-command: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | pushd "$(dirname "${BASH_SOURCE[0]}")"/../.. 5 | 6 | TOOL_VERSION_FILES=() 7 | mapfile -d $'\0' TOOL_VERSION_FILES < <(fd .tool-versions --hidden --absolute-path --print0) 8 | 9 | for file in "${TOOL_VERSION_FILES[@]}"; do 10 | echo "Installing asdf dependencies as defined in ${file}:" 11 | parent=$(dirname "${file}") 12 | pushd "${parent}" 13 | 14 | asdf install 15 | 16 | popd 17 | done 18 | 19 | popd 20 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .editorconfig 3 | .git/ 4 | .gitignore 5 | .prettierignore 6 | .vscode/ 7 | .yarnrc 8 | *.log 9 | *.pem 10 | buildkite.yml 11 | deploy.sh 12 | extension/ 13 | kubernetes/ 14 | package.json 15 | prettier.config.js 16 | README.md 17 | renovate.json 18 | server/src/ 19 | tsconfig.json 20 | tslint.json 21 | yarn.lock 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | insert_final_newline = true 3 | end_of_line = lf 4 | charset = utf-8 5 | trim_trailing_whitespace = true 6 | indent_style = space 7 | indent_size = 4 8 | 9 | [*.{json,js,yml,yaml,md}] 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/articles/about-code-owners 2 | 3 | * @sourcegraph/code-intel 4 | src @felixfbecker 5 | 6 | # Renovate PR reviewers are controlled with Renovate config 7 | package.json 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Test plan 2 | 3 | 10 | -------------------------------------------------------------------------------- /.github/workflows/pr-auditor.yml: -------------------------------------------------------------------------------- 1 | name: pr-auditor 2 | on: 3 | pull_request: 4 | types: [ closed, edited, opened ] 5 | 6 | jobs: 7 | run: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: { repository: 'sourcegraph/sourcegraph' } 12 | - uses: actions/setup-go@v2 13 | with: { go-version: '1.18' } 14 | 15 | - run: ./dev/pr-auditor/check-pr.sh 16 | env: 17 | GITHUB_EVENT_PATH: ${{ env.GITHUB_EVENT_PATH }} 18 | GITHUB_TOKEN: ${{ secrets.CODENOTIFY_GITHUB_TOKEN }} 19 | GITHUB_RUN_URL: https://github.com/sourcegraph/infrastructure/actions/runs/${{ github.run_id }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | .cache/ 4 | *.log 5 | *.tsbuildinfo 6 | out/ 7 | lib/ 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | dist/ 4 | .cache/ 5 | lib/ 6 | .github/ 7 | buildkite.yml 8 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | python system 2 | yarn 1.22.11 3 | nodejs 16.7.0 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Server", 11 | "program": "${workspaceFolder}/dist/server.js", 12 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 13 | "runtimeArgs": ["--enable-source-maps"], 14 | "skipFiles": ["async_hooks.js"], 15 | }, 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.format.semicolons": "remove", 4 | "typescript.preferences.quoteStyle": "single", 5 | "tslint.autoFixOnSave": true, 6 | "editor.formatOnSave": true, 7 | "files.eol": "\n", 8 | "search.exclude": { 9 | "**/node_modules": true, 10 | "**/bower_components": true, 11 | "lib": true, 12 | "dist": true, 13 | ".cache": true, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch-ts", 9 | "problemMatcher": ["$tsc-watch"], 10 | "group": "build", 11 | "isBackground": true, 12 | "presentation": { 13 | "reveal": "never", 14 | }, 15 | "runOptions": { 16 | "runOn": "folderOpen", 17 | }, 18 | }, 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.org" 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:13.14.0-alpine@sha256:4d6b35f8d14b2f476005883f9dec0a93c83213838fd3def3db0d130b1b148653 2 | 3 | # Use tini (https://github.com/krallin/tini) for proper signal handling. 4 | RUN apk add --no-cache tini 5 | ENTRYPOINT ["/sbin/tini", "--"] 6 | 7 | # Allow to mount a custom yarn config 8 | RUN mkdir /yarn-config \ 9 | && touch /yarn-config/.yarnrc \ 10 | && ln -s /yarn-config/.yarnrc /usr/local/share/.yarnrc 11 | 12 | # Add git, needed for yarn 13 | RUN apk add --no-cache bash git openssh 14 | 15 | COPY ./ /srv 16 | 17 | WORKDIR /srv/dist 18 | EXPOSE 80 443 8080 19 | CMD ["node", "--max_old_space_size=4096", "./server.js"] 20 | USER node 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sourcegraph 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This repository has been superseded by [scip-typescript](https://github.com/sourcegraph/scip-typescript). 2 | 3 | Using this code is not supported. 4 | 5 | --- 6 | 7 | # Language server for TypeScript/JavaScript 8 | 9 | [![Build status](https://badge.buildkite.com/6399fffb5ec930dde31cf654b2cd694b56f2233345e2bc0db4.svg?branch=master)](https://buildkite.com/sourcegraph/sourcegraph-typescript) 10 | 11 | This is a backend for the [Sourcegraph TypeScript extension](https://github.com/sourcegraph/code-intel-extensions/tree/master/extensions/typescript), 12 | speaking the [Language Server Protocol](https://github.com/Microsoft/language-server-protocol) over WebSockets. 13 | 14 | It supports editor features such as go-to-definition, hover, and find-references for TypeScript and JavaScript projects, 15 | including support for dependencies and cross-repository code intelligence. 16 | 17 | Monitoring is available through OpenTracing (Jaeger) and Prometheus. 18 | 19 | ## How it works 20 | 21 | Check out [@felixfbecker](https://github.com/felixfecker)'s talk at FOSDEM for an overview & deep dive of the architecture: 22 | 23 |

24 | 25 | Talk Recording: Advanced TypeScript Tooling at Scale 30 | 31 |

32 | 33 | Topics covered: 34 | 35 | - Basic features 36 | - LSP WebSocket architecture 37 | - Repository contents through the Sourcegraph raw HTTP API 38 | - Cross-repository Go-to-Definition 39 | - Cross-repository Find-References 40 | 41 | ## Deployment 42 | 43 | The server is available as a Docker image `sourcegraph/lang-typescript` from Docker Hub. 44 | 45 | ### 🔐 Secure deployment 🔐 46 | 47 | If you have private code, we recommend deploying the language server behind an 48 | auth proxy (such as the example below using HTTP basic authentication in NGINX), a firewall, or a VPN. 49 | 50 | ### HTTP basic authentication 51 | 52 | You can prevent unauthorized access to the language server by enforcing HTTP basic authentication in nginx, which comes with the sourcegraph/server image. At a high level, you'll create a secret then put it in both the nginx config and in your Sourcegraph global settings so that logged-in users are authenticated when their browser makes requests to the TypeScript language server. 53 | 54 | Here's how to set it up: 55 | 56 | Create an `.htpasswd` file in the Sourcegraph config directory with one entry: 57 | 58 | ``` 59 | $ htpasswd -c ~/.sourcegraph/config/.htpasswd langserveruser 60 | New password: 61 | Re-type new password: 62 | Adding password for user langserveruser 63 | ``` 64 | 65 | Add a location directive the [nginx.conf](https://docs.sourcegraph.com/admin/nginx) that will route requests to the TypeScript language server: 66 | 67 | ```nginx 68 | ... 69 | http { 70 | ... 71 | server { 72 | ... 73 | location / { 74 | ... 75 | } 76 | 77 | location /typescript { 78 | proxy_pass http://host.docker.internal:8080; 79 | proxy_http_version 1.1; 80 | proxy_set_header Upgrade $http_upgrade; 81 | proxy_set_header Connection "Upgrade"; 82 | 83 | auth_basic "basic authentication is required to access the language server"; 84 | auth_basic_user_file /etc/sourcegraph/.htpasswd; 85 | } 86 | } 87 | } 88 | ``` 89 | 90 | - If you're running the quickstart on Linux, change `host.docker.internal` to the output of `ip addr show docker0 | grep -Po 'inet \K[\d.]+'`. 91 | - If you're using [Kubernetes](#using-kubernetes) (e.g. [deploy-sourcegraph](https://github.com/sourcegraph/deploy-sourcegraph)), change `host.docker.internal` to `lang-typescript`. 92 | 93 | Restart the sourcegraph/server container (or nginx deployment if deployed to Kubernetes) to pick up the configuration change. 94 | 95 | After deploying the language server, unauthenticated access to `http://localhost:7080/typescript` (or https://sourcegraph.example.com/typescript) should be blocked, but code intelligence should work when you're logged in. 96 | 97 | You can always revoke the `PASSWORD` by deleting the `.htpasswd` file and restarting nginx. 98 | 99 | ### Using Docker 100 | 101 | Run the server: 102 | 103 | ```sh 104 | docker run -p 8080:8080 sourcegraph/lang-typescript 105 | ``` 106 | 107 | You can verify it's up and running with [`ws`](https://github.com/hashrocket/ws): 108 | 109 | ```sh 110 | $ go get -u github.com/hashrocket/ws 111 | $ ws ws://localhost:8080 112 | > 113 | ``` 114 | 115 | #### TLS in Docker 116 | 117 | To enable the use of Websocket with SSL pass the key/certificate pair as environment variables to the docker container. 118 | 119 | ``` 120 | docker run -p 8080:8080 -e TLS_KEY="$(cat sourcegraph.example.com.key)" -e TLS_CERT="$(cat sourcegraph.example.com.crt)" sourcegraph/lang-typescript 121 | ``` 122 | 123 | To reuse the self-signed certificate created by following the steps [here](https://docs.sourcegraph.com/admin/nginx#tls-https) add these parameters to the run command above: 124 | 125 | ``` 126 | -e NODE_EXTRA_CA_CERTS=/home/node/sourcegraph.example.com.crt -v ~/.sourcegraph/config:/home/node 127 | ``` 128 | 129 | The self signed certificate's `Common Name (CN)` should be the host name of your host. Also make sure you use Websocket with SSL in your Sourcegraph settings to connect to the language server: 130 | 131 | ```json 132 | "typescript.serverUrl": "wss://localhost:8080" 133 | ``` 134 | 135 | ### Authentication proxies and firewalls 136 | 137 | Some customers deploy Sourcegraph behind an authentication proxy or firewall. If you do this, we 138 | recommend deploying the language server behind the proxy so that it can issue requests directly to 139 | Sourcegraph without going through the proxy. (Otherwise, you will need to configure the language 140 | server to authenticate through your proxy.) Make sure you set `typescript.sourcegraphUrl` to the URL 141 | that the language server should use to reach Sourcegraph, which is likely different from the URL 142 | that end users use. 143 | 144 | ### Using Kubernetes 145 | 146 | To deploy the language server with Kubernetes, use a deployment like this: 147 | 148 | ```yaml 149 | apiVersion: apps/v1 150 | kind: Deployment 151 | metadata: 152 | name: lang-typescript 153 | spec: 154 | replicas: 4 # adjust as needed 155 | selector: 156 | matchLabels: 157 | app: lang-typescript 158 | template: 159 | metadata: 160 | labels: 161 | app: lang-typescript 162 | spec: 163 | containers: 164 | - name: lang-typescript 165 | image: sourcegraph/lang-typescript 166 | ports: 167 | - containerPort: 8080 168 | name: wss 169 | env: 170 | # TLS certificate and key to secure the WebSocket connection (optional) 171 | - name: TLS_CERT 172 | value: ... your TLS certificate ... 173 | - name: TLS_KEY 174 | value: ... your TLS key ... 175 | # Resources to provision for the server (adjust as needed) 176 | resources: 177 | limits: 178 | cpu: '4' 179 | memory: 5Gi 180 | requests: 181 | cpu: 500m 182 | memory: 2Gi 183 | # Probes the server periodically to see if it is healthy 184 | livenessProbe: 185 | initialDelaySeconds: 30 186 | tcpSocket: 187 | port: wss 188 | timeoutSeconds: 5 189 | readinessProbe: 190 | tcpSocket: 191 | port: wss 192 | ``` 193 | 194 | With a corresponding service: 195 | 196 | ```yaml 197 | apiVersion: v1 198 | kind: Service 199 | metadata: 200 | labels: 201 | app: lang-typescript 202 | deploy: lang-typescript 203 | name: lang-typescript 204 | spec: 205 | ports: 206 | - name: wss 207 | port: 443 208 | targetPort: wss 209 | selector: 210 | app: lang-typescript 211 | type: LoadBalancer 212 | ``` 213 | 214 | #### TLS 215 | 216 | To enable TLS, set the `TLS_KEY` and `TLS_CERT` environment variables. TLS optional but **strongly recommended** for production deployments. 217 | 218 | #### Enabling OpenTracing 219 | 220 | The server can report spans through OpenTracing to diagnose issues. 221 | If the environment variable `JAEGER_DISABLED` is not set, 222 | the server will send tracing data to a Jaeger agent (by default `localhost:6832`). 223 | 224 | Tracing can be further configured through [environment variables](https://github.com/jaegertracing/jaeger-client-node#environment-variables). Example: 225 | 226 | ```diff 227 | env: 228 | + - name: JAEGER_AGENT_HOST 229 | + value: my-jaeger-agent 230 | ``` 231 | 232 | #### Enabling Prometheus metrics 233 | 234 | The server exposes metrics on port 6060 that can be scraped by Prometheus. 235 | 236 | #### Improving performance with an SSD 237 | 238 | To improve performance of dependency installation, the server can be configured to use a mounted SSD at a given directory by setting the `CACHE_DIR` environment variable. The instructions for how to mount a SSD depend on your deployment environment. 239 | 240 | 1. Add a volume for the mount path of the SSD: 241 | 242 | ```diff 243 | spec: 244 | + volumes: 245 | + - hostPath: 246 | + path: /path/to/mounted/ssd 247 | + name: cache-ssd 248 | ``` 249 | 250 | For example, Google Cloud Platform mounts the first SSD disk to `/mnt/disks/ssd0`. 251 | 252 | 2. Add a volume mount to the container spec: 253 | 254 | ```diff 255 | image: sourcegraph/lang-typescript 256 | + volumeMounts: 257 | + - mountPath: /mnt/cache 258 | + name: cache-ssd 259 | ``` 260 | 261 | 3. Tell the language server to use the mount as the root for temporary directories: 262 | 263 | ```diff 264 | env: 265 | + - name: CACHE_DIR 266 | + value: /mnt/cache 267 | ``` 268 | 269 | #### Improving performance with an npm registry proxy 270 | 271 | To further speed up dependency installation, all npm registry requests can be proxied through a cache running on the same node. 272 | 273 | Example deployment for Kubernetes: 274 | 275 | ```yaml 276 | apiVersion: apps/v1 277 | kind: Deployment 278 | metadata: 279 | name: npm-proxy 280 | spec: 281 | minReadySeconds: 10 282 | replicas: 1 283 | revisionHistoryLimit: 10 284 | strategy: 285 | rollingUpdate: 286 | maxSurge: 1 287 | maxUnavailable: 1 288 | type: RollingUpdate 289 | template: 290 | metadata: 291 | labels: 292 | app: npm-proxy 293 | spec: 294 | containers: 295 | - image: sourcegraph/npm-proxy:latest 296 | name: npm-proxy 297 | ports: 298 | - containerPort: 8080 299 | name: http 300 | resources: 301 | limits: 302 | cpu: '1' 303 | memory: 1Gi 304 | volumeMounts: 305 | - mountPath: /cache 306 | name: npm-proxy-cache 307 | volumes: 308 | - name: npm-proxy-cache 309 | persistentVolumeClaim: 310 | claimName: npm-proxy 311 | --- 312 | apiVersion: v1 313 | kind: PersistentVolumeClaim 314 | metadata: 315 | annotations: 316 | volume.beta.kubernetes.io/storage-class: default 317 | name: npm-proxy 318 | spec: 319 | accessModes: 320 | - ReadWriteOnce 321 | resources: 322 | requests: 323 | storage: 100Gi 324 | --- 325 | apiVersion: v1 326 | kind: Service 327 | metadata: 328 | labels: 329 | app: npm-proxy 330 | name: npm-proxy 331 | spec: 332 | ports: 333 | - name: http 334 | port: 8080 335 | targetPort: http 336 | selector: 337 | app: npm-proxy 338 | type: ClusterIP 339 | ``` 340 | 341 | Then define a `.yarnrc` as a config map that points to the proxy: 342 | 343 | ```yaml 344 | apiVersion: v1 345 | data: 346 | .yarnrc: | 347 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 348 | # yarn lockfile v1 349 | 350 | 351 | https-proxy "http://npm-proxy:8080" 352 | proxy "http://npm-proxy:8080" 353 | strict-ssl false 354 | kind: ConfigMap 355 | metadata: 356 | name: yarn-config 357 | ``` 358 | 359 | and mount it into the container: 360 | 361 | ```diff 362 | name: lang-typescript 363 | + volumeMounts: 364 | + - mountPath: /yarn-config 365 | + name: yarn-config 366 | ``` 367 | 368 | ```diff 369 | spec: 370 | + volumes: 371 | + - configMap: 372 | + name: yarn-config 373 | + name: yarn-config 374 | ``` 375 | 376 | ## Support for dependencies on private packages and git repositories 377 | 378 | Dependencies on private npm packages and private registries is supported by setting the `typescript.npmrc` setting. 379 | It contains the same key/value settings as your `.npmrc` file in your home folder, and therefor supports the same scoping to registries and package scopes. 380 | See https://docs.npmjs.com/misc/config#config-settings for more information on what is possible to configure in `.npmrc`. 381 | 382 | Example: 383 | 384 | ```json 385 | "typescript.npmrc": { 386 | "//registry.npmjs.org/:_authToken": "asfdh21e-1234-asdn-123v-1234asdb2" 387 | } 388 | ``` 389 | 390 | For dependencies on private git repositories, mount an SSH key into `~/.ssh`. 391 | 392 | ## Contributing 393 | 394 | You need NodeJS >=11.1.0 and yarn installed. 395 | 396 | ```sh 397 | # Install dependencies 398 | yarn 399 | # Build the extension and the server 400 | yarn run build 401 | ``` 402 | -------------------------------------------------------------------------------- /buildkite.yml: -------------------------------------------------------------------------------- 1 | env: 2 | FORCE_COLOR: '1' 3 | 4 | steps: 5 | - label: ':lock: security - checkov' 6 | command: ./ci-checkov.sh 7 | agents: { queue: standard } 8 | 9 | # Run tests 10 | - command: |- 11 | yarn 12 | yarn run yarn-deduplicate --strategy fewer --fail --list 13 | yarn run prettier-check 14 | yarn run tslint 15 | yarn run build 16 | label: ':typescript:' 17 | 18 | - wait 19 | 20 | # Build & deploy Docker image 21 | - command: ./deploy.sh 22 | branches: master 23 | label: ':rocket:' 24 | concurrency: 1 25 | concurrency_group: deploy 26 | -------------------------------------------------------------------------------- /ci-checkov.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Set this to fail on the install 3 | set -euxo pipefail 4 | 5 | # Install and run the plugin for checkov 6 | # Use the full path to run pip3.10 7 | pip3 install checkov 8 | 9 | # List of checks we do not want to run here 10 | # This is a living list and will see additions and mostly removals over time. 11 | SKIP_CHECKS="CKV_GCP_22,CKV_GCP_66,CKV_GCP_13,CKV_GCP_71,CKV_GCP_61,CKV_GCP_21,CKV_GCP_65,CKV_GCP_67,CKV_GCP_20,CKV_GCP_69,CKV_GCP_12,CKV_GCP_24,CKV_GCP_25,CKV_GCP_64,CKV_GCP_68,CKV2_AWS_5,CKV2_GCP_3,CKV2_GCP_5,CKV_AWS_23,CKV_GCP_70,CKV_GCP_62,CKV_GCP_62,CKV_GCP_62,CKV_GCP_62,CKV_GCP_29,CKV_GCP_39" 12 | 13 | set +x 14 | # In case no terraform code is present 15 | echo "--- Starting Checkov..." 16 | echo "Note: If there is no output below here then no terraform code was found to scan. All good!" 17 | echo "===========================================================================================" 18 | 19 | # Set not to fail on non-zero exit code 20 | set +e 21 | # Run checkov 22 | python3 -m checkov.main --skip-check $SKIP_CHECKS --quiet --framework terraform --compact -d . 23 | 24 | # Options 25 | # --quiet: Only show failing tests 26 | # --compact: Do not show code snippets 27 | # --framework: Only scan terraform code 28 | 29 | # Capture the error code 30 | CHECKOV_EXIT_CODE="$?" 31 | 32 | # We check the exit code and display a warning if anything was found 33 | if [[ "$CHECKOV_EXIT_CODE" != 0 ]]; then 34 | echo "^^^ +++" 35 | echo "Possible Terraform security issues found. " 36 | echo "Please refer to the Sourcegraph handbook for guidance: https://handbook.sourcegraph.com/product-engineering/engineering/cloud/security/checkov" 37 | fi 38 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | cd $(dirname "${BASH_SOURCE[0]}") 4 | 5 | # Install dependencies 6 | yarn 7 | 8 | # Build 9 | yarn run build 10 | 11 | # Build image 12 | VERSION=$(printf "%05d" $BUILDKITE_BUILD_NUMBER)_$(date +%Y-%m-%d)_$(git rev-parse --short HEAD) 13 | docker build -t sourcegraph/lang-typescript:$VERSION . 14 | 15 | # Upload to Docker Hub 16 | docker push sourcegraph/lang-typescript:$VERSION 17 | docker tag sourcegraph/lang-typescript:$VERSION sourcegraph/lang-typescript:latest 18 | docker push sourcegraph/lang-typescript:latest 19 | docker tag sourcegraph/lang-typescript:$VERSION sourcegraph/lang-typescript:insiders 20 | docker push sourcegraph/lang-typescript:insiders 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "description": "TypeScript/JavaScript code intelligence", 4 | "private": true, 5 | "publisher": "sourcegraph", 6 | "engines": { 7 | "node": ">=11.1.0" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/sourcegraph/sourcegraph-typescript" 12 | }, 13 | "license": "MIT", 14 | "scripts": { 15 | "prettier": "prettier --list-different --write \"**/{*.{js?(on),ts,md,yml},.*.yml}\"", 16 | "prettier-check": "yarn run prettier --write=false", 17 | "tslint": "tslint -p tsconfig.json --format stylish", 18 | "build-ts": "tsc -b .", 19 | "watch-ts": "tsc -b . -w", 20 | "build": "yarn run build-ts" 21 | }, 22 | "devDependencies": { 23 | "@sourcegraph/prettierrc": "^3.0.3", 24 | "@sourcegraph/tsconfig": "^4.0.1", 25 | "@sourcegraph/tslint-config": "^13.4.0", 26 | "@types/express": "4.17.13", 27 | "@types/got": "9.6.12", 28 | "@types/highlight.js": "9.12.4", 29 | "@types/ini": "1.3.31", 30 | "@types/jaeger-client": "3.15.3", 31 | "@types/json5": "0.0.30", 32 | "@types/lodash": "4.14.182", 33 | "@types/mkdirp-promise": "5.0.0", 34 | "@types/mz": "2.7.4", 35 | "@types/node": "12.20.54", 36 | "@types/relateurl": "0.2.29", 37 | "@types/rmfr": "2.0.1", 38 | "@types/semver": "7.1.0", 39 | "@types/tail": "2.0.0", 40 | "@types/tar": "6.1.1", 41 | "@types/type-is": "1.6.3", 42 | "@types/uuid": "7.0.0", 43 | "@types/ws": "7.4.4", 44 | "dot-json": "^1.2.0", 45 | "husky": "^4.2.3", 46 | "json-schema-to-typescript": "^8.1.0", 47 | "lnfs-cli": "^2.1.0", 48 | "parcel-bundler": "^1.12.4", 49 | "prettier": "^1.19.1", 50 | "tslint": "^6.1.3", 51 | "yarn-deduplicate": "^2.0.0" 52 | }, 53 | "dependencies": { 54 | "@sourcegraph/typescript-language-server": "^0.3.7-fork", 55 | "abort-controller": "^3.0.0", 56 | "axios": "^0.19.2", 57 | "express": "^4.17.1", 58 | "fast-glob": "^3.2.2", 59 | "got": "^10.6.0", 60 | "highlight.js": "^9.18.1", 61 | "ini": "^1.3.5", 62 | "ix": "^3.0.2", 63 | "jaeger-client": "^3.17.2", 64 | "json5": "^2.1.1", 65 | "lodash": "^4.17.15", 66 | "mkdirp-promise": "^5.0.1", 67 | "mz": "^2.7.0", 68 | "npm-registry-fetch": "^8.0.0", 69 | "opentracing": "^0.14.4", 70 | "pretty-bytes": "^5.3.0", 71 | "prom-client": "^12.0.0", 72 | "relateurl": "^0.2.7", 73 | "rmfr": "^2.0.0", 74 | "rxjs": "^6.5.4", 75 | "semver": "^7.1.3", 76 | "source-map": "^0.7.3", 77 | "source-map-support": "^0.5.16", 78 | "tagged-template-noop": "^2.1.1", 79 | "tail": "^2.0.3", 80 | "tar": "^6.0.1", 81 | "type-is": "^1.6.18", 82 | "typescript": "^3.8.3", 83 | "uuid": "^7.0.2", 84 | "vscode-languageserver-protocol": "^3.15.3", 85 | "vscode-ws-jsonrpc": "^0.2.0", 86 | "ws": "^7.4.6", 87 | "yarn": "^1.22.1" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@sourcegraph/prettierrc') 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/renovate", 3 | "extends": ["github>sourcegraph/renovate-config"], 4 | "prHourlyLimit": 0, 5 | "lockFileMaintenance": { 6 | "enabled": false 7 | }, 8 | "rangeStrategy": "bump", 9 | "postUpdateOptions": ["yarnDedupeFewer"], 10 | "masterIssue": true, 11 | "semanticCommits": false, 12 | "docker": { 13 | "enabled": true, 14 | "pinDigests": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/cancellation.ts: -------------------------------------------------------------------------------- 1 | import axios, { CancelToken } from 'axios' 2 | import { CancellationToken, CancellationTokenSource } from 'vscode-jsonrpc' 3 | 4 | export interface AbortError extends Error { 5 | name: 'AbortError' 6 | } 7 | 8 | /** 9 | * Creates an Error with name "AbortError" 10 | */ 11 | export const createAbortError = (): AbortError => Object.assign(new Error('Aborted'), { name: 'AbortError' as const }) 12 | 13 | /** 14 | * Returns true if the given value is an AbortError 15 | */ 16 | export const isAbortError = (err: any): err is AbortError => 17 | typeof err === 'object' && err !== null && err.name === 'AbortError' 18 | 19 | export function throwIfAbortError(err: unknown): void { 20 | if (isAbortError(err)) { 21 | throw err 22 | } 23 | } 24 | 25 | /** 26 | * Throws an AbortError if the given AbortSignal is already aborted 27 | */ 28 | export function throwIfCancelled(token: CancellationToken): void { 29 | if (token.isCancellationRequested) { 30 | throw createAbortError() 31 | } 32 | } 33 | 34 | export function tryCancel(token: CancellationTokenSource): void { 35 | try { 36 | token.cancel() 37 | } catch (err) { 38 | // ignore 39 | } 40 | } 41 | 42 | export function toAxiosCancelToken(token: CancellationToken): CancelToken { 43 | const source = axios.CancelToken.source() 44 | token.onCancellationRequested(() => source.cancel()) 45 | return source.token 46 | } 47 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export interface Settings { 9 | /** 10 | * Whether to use pre-computed LSIF data for code intelligence (such as hovers, definitions, and references). See https://docs.sourcegraph.com/user/code_intelligence/lsif. 11 | */ 12 | 'codeIntel.lsif'?: boolean 13 | /** 14 | * Trace Sourcegraph search API requests in the console. 15 | */ 16 | 'basicCodeIntel.debug.traceSearch'?: boolean 17 | /** 18 | * Whether to use only indexed requests to the search API. 19 | */ 20 | 'basicCodeIntel.indexOnly'?: boolean 21 | /** 22 | * The timeout (in milliseconds) for un-indexed search requests. 23 | */ 24 | 'basicCodeIntel.unindexedSearchTimeout'?: number 25 | /** 26 | * The address of the WebSocket language server to connect to (e.g. ws://host:8080). 27 | */ 28 | 'typescript.serverUrl'?: string 29 | /** 30 | * The address of the Sourcegraph instance from the perspective of the TypeScript language server. 31 | */ 32 | 'typescript.sourcegraphUrl'?: string 33 | /** 34 | * The access token for the language server to use to fetch files from the Sourcegraph API. The extension will create this token and save it in your settings automatically. 35 | */ 36 | 'typescript.accessToken'?: string 37 | /** 38 | * Whether or not a second references provider for external references will be registered (defaults to false). 39 | */ 40 | 'typescript.showExternalReferences'?: boolean 41 | /** 42 | * The maximum number of dependent packages to look in when searching for external references for a symbol (defaults to 20). 43 | */ 44 | 'typescript.maxExternalReferenceRepos'?: number 45 | /** 46 | * Whether to report progress while fetching sources, installing dependencies etc. (Default: true) 47 | */ 48 | 'typescript.progress'?: boolean 49 | /** 50 | * Whether to show compile errors on lines (Default: false) 51 | */ 52 | 'typescript.diagnostics.enable'?: boolean 53 | /** 54 | * Settings to be written into an npmrc in key/value format. Can be used to specify custom registries and tokens. 55 | */ 56 | 'typescript.npmrc'?: { 57 | [k: string]: any 58 | } 59 | /** 60 | * Whether to restart the language server after dependencies were installed (default true) 61 | */ 62 | 'typescript.restartAfterDependencyInstallation'?: boolean 63 | /** 64 | * The log level to pass to the TypeScript language server. Logs will be forwarded to the browser console with the prefix [langserver]. 65 | */ 66 | 'typescript.langserver.log'?: false | 'log' | 'info' | 'warn' | 'error' 67 | /** 68 | * The log level to pass to tsserver. Logs will be forwarded to the browser console with the prefix [tsserver]. 69 | */ 70 | 'typescript.tsserver.log'?: false | 'terse' | 'normal' | 'requestTime' | 'verbose' 71 | } 72 | -------------------------------------------------------------------------------- /src/dependencies.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'mz/fs' 2 | import npmFetch, { NpmOptions } from 'npm-registry-fetch' 3 | import { Span, Tracer } from 'opentracing' 4 | import * as semver from 'semver' 5 | import { URL } from 'url' 6 | import { CancellationToken } from 'vscode-jsonrpc' 7 | import { throwIfCancelled } from './cancellation' 8 | import { Logger } from './logging' 9 | import { ResourceNotFoundError, ResourceRetrieverPicker, walkUp } from './resources' 10 | import { logErrorEvent, tracePromise } from './tracing' 11 | 12 | export async function fetchPackageMeta( 13 | packageName: string, 14 | versionSpec = 'latest', 15 | npmConfig: NpmOptions 16 | ): Promise { 17 | const options = { ...npmConfig, spec: packageName } 18 | if (!packageName.startsWith('@') && (versionSpec === 'latest' || semver.valid(versionSpec))) { 19 | // Request precise version 20 | const result = await npmFetch.json(`/${packageName}/${versionSpec}`, options) 21 | return result 22 | } 23 | // Resolve version 24 | const result = await npmFetch.json(`/${packageName}`, options) 25 | if (result.versions[result['dist-tags'][versionSpec]]) { 26 | return result.versions[result['dist-tags'][versionSpec]] 27 | } 28 | const versions = Object.keys(result.versions) 29 | const version = semver.maxSatisfying(versions, versionSpec) 30 | if (!version) { 31 | throw new Error(`Version ${packageName}@${versionSpec} does not exist`) 32 | } 33 | return result.versions[version] 34 | } 35 | 36 | /** 37 | * Checks if a dependency from a package.json should be installed or not by checking whether it contains TypeScript typings. 38 | */ 39 | function hasTypes(name: string, range: string, npmConfig: NpmOptions, tracer: Tracer, span?: Span): Promise { 40 | return tracePromise('Fetch package metadata', tracer, span, async span => { 41 | span.setTag('name', name) 42 | const version = semver.validRange(range) || 'latest' 43 | span.setTag('version', version) 44 | const dependencyPackageJson = await fetchPackageMeta(name, version, npmConfig) 45 | // Keep packages only if they have a types or typings field 46 | return !!dependencyPackageJson.typings || !!dependencyPackageJson.types 47 | }) 48 | } 49 | 50 | /** 51 | * Removes all dependencies from a package.json that do not contain TypeScript type declaration files. 52 | * 53 | * @param packageJsonPath File path to a package.json 54 | * @return Whether the package.json contained any dependencies 55 | */ 56 | export async function filterDependencies( 57 | packageJsonPath: string, 58 | { 59 | npmConfig, 60 | logger, 61 | tracer, 62 | span, 63 | token, 64 | }: { 65 | npmConfig: NpmOptions 66 | logger: Logger 67 | tracer: Tracer 68 | span?: Span 69 | token: CancellationToken 70 | } 71 | ): Promise { 72 | return await tracePromise('Filter dependencies', tracer, span, async span => { 73 | span.setTag('packageJsonPath', packageJsonPath) 74 | logger.log('Filtering package.json at ', packageJsonPath) 75 | const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) 76 | const excluded: string[] = [] 77 | const included: string[] = [] 78 | await Promise.all( 79 | ['dependencies', 'devDependencies', 'optionalDependencies'].map(async dependencyType => { 80 | const dependencies: { [name: string]: string } = packageJson[dependencyType] 81 | if (!dependencies) { 82 | return 83 | } 84 | await Promise.all( 85 | Object.entries(dependencies).map(async ([name, range]) => { 86 | throwIfCancelled(token) 87 | try { 88 | if (name.startsWith('@types/') || (await hasTypes(name, range, npmConfig, tracer, span))) { 89 | included.push(name) 90 | } else { 91 | excluded.push(name) 92 | dependencies[name] = undefined! 93 | } 94 | } catch (err) { 95 | throwIfCancelled(token) 96 | included.push(name) 97 | logger.error(`Error inspecting dependency ${name}@${range} in ${packageJsonPath}`, err) 98 | logErrorEvent(span, err) 99 | } 100 | }) 101 | ) 102 | }) 103 | ) 104 | span.setTag('excluded', excluded.length) 105 | span.setTag('included', included.length) 106 | logger.log(`Excluding ${excluded.length} dependencies`) 107 | logger.log(`Keeping ${included.length} dependencies`) 108 | // Only write if there is any change to dependencies 109 | if (included.length > 0 && excluded.length > 0) { 110 | await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)) 111 | } 112 | return included.length > 0 113 | }) 114 | } 115 | 116 | export interface PackageJson { 117 | name: string 118 | version: string 119 | repository?: 120 | | string 121 | | { 122 | type: string 123 | url: string 124 | 125 | /** 126 | * https://github.com/npm/rfcs/blob/d39184cdedc000aa8e60b4d63878b834aa5f0ff0/accepted/0000-monorepo-subdirectory-declaration.md 127 | */ 128 | directory?: string 129 | } 130 | /** Commit SHA1 of the repo at the time of publishing */ 131 | gitHead?: string 132 | types?: string 133 | typings?: string 134 | dependencies?: Record 135 | devDependencies?: Record 136 | } 137 | 138 | /** 139 | * Finds the closest package.json for a given URL. 140 | * 141 | * @param resource The URL from which to walk upwards. 142 | * @param rootUri A URL at which to stop searching. If not given, defaults to the root of the resource URL. 143 | */ 144 | export async function findClosestPackageJson( 145 | resource: URL, 146 | pickResourceRetriever: ResourceRetrieverPicker, 147 | rootUri = Object.assign(new URL(resource.href), { pathname: '' }), 148 | { 149 | span = new Span(), 150 | tracer = new Tracer(), 151 | }: { 152 | span?: Span 153 | tracer?: Tracer 154 | } = {} 155 | ): Promise<[URL, PackageJson]> { 156 | return await tracePromise( 157 | 'Find closest package.json', 158 | tracer, 159 | span, 160 | async (span): Promise<[URL, PackageJson]> => { 161 | for (const parent of walkUp(resource)) { 162 | if (!parent.href.startsWith(rootUri.href)) { 163 | break 164 | } 165 | const packageJsonUri = new URL('package.json', parent.href) 166 | try { 167 | const packageJson = await readPackageJson(packageJsonUri, pickResourceRetriever, { span, tracer }) 168 | return [packageJsonUri, packageJson] 169 | } catch (err) { 170 | if (err instanceof ResourceNotFoundError) { 171 | continue 172 | } 173 | throw err 174 | } 175 | } 176 | throw new Error(`No package.json found for ${resource} under root ${rootUri}`) 177 | } 178 | ) 179 | } 180 | 181 | export const isDefinitelyTyped = (uri: URL): boolean => uri.pathname.includes('DefinitelyTyped/DefinitelyTyped') 182 | 183 | /** 184 | * Finds the package name and package root that the given URI belongs to. 185 | * Handles special repositories like DefinitelyTyped. 186 | */ 187 | export async function findPackageRootAndName( 188 | uri: URL, 189 | pickResourceRetriever: ResourceRetrieverPicker, 190 | { span, tracer }: { span: Span; tracer: Tracer } 191 | ): Promise<[URL, string]> { 192 | // Special case: if the definition is in DefinitelyTyped, the package name is @types/[/] 193 | if (isDefinitelyTyped(uri)) { 194 | const dtMatch = uri.pathname.match(/\/types\/([^\/]+)\//) 195 | if (dtMatch) { 196 | const packageRoot = new URL(uri.href) 197 | // Strip everything after types/ (except the optional version directory) 198 | packageRoot.pathname = packageRoot.pathname.replace(/\/types\/([^\/]+)\/(v[^\/]+\/)?.*$/, '/types/$1/$2') 199 | const packageName = '@types/' + dtMatch[1] 200 | return [packageRoot, packageName] 201 | } 202 | } 203 | // Find containing package 204 | const [packageJsonUrl, packageJson] = await findClosestPackageJson(uri, pickResourceRetriever, undefined, { 205 | span, 206 | tracer, 207 | }) 208 | if (!packageJson.name) { 209 | throw new Error(`package.json at ${packageJsonUrl} does not contain a name`) 210 | } 211 | const packageRoot = new URL('.', packageJsonUrl) 212 | return [packageRoot, packageJson.name] 213 | } 214 | 215 | export async function readPackageJson( 216 | pkgJsonUri: URL, 217 | pickResourceRetriever: ResourceRetrieverPicker, 218 | options: { span: Span; tracer: Tracer } 219 | ): Promise { 220 | return JSON.parse(await pickResourceRetriever(pkgJsonUri).fetch(pkgJsonUri, options)) 221 | } 222 | 223 | export function cloneUrlFromPackageMeta(packageMeta: PackageJson): string { 224 | if (!packageMeta.repository) { 225 | throw new Error('Package data does not contain repository field') 226 | } 227 | let repoUrl = typeof packageMeta.repository === 'string' ? packageMeta.repository : packageMeta.repository.url 228 | // GitHub shorthand 229 | const shortHandMatch = repoUrl.match(/^(?:github:)?([^\/]+\/[^\/]+)$/) 230 | if (shortHandMatch) { 231 | repoUrl = 'https://github.com/' + shortHandMatch[1] 232 | } 233 | return repoUrl 234 | } 235 | 236 | /** 237 | * @param filePath e.g. `/foo/node_modules/pkg/dist/bar.ts` 238 | * @returns e.g. `foo/node_modules/pkg` 239 | */ 240 | export function resolveDependencyRootDir(filePath: string): string { 241 | const parts = filePath.split('/') 242 | while ( 243 | parts.length > 0 && 244 | !( 245 | parts[parts.length - 2] === 'node_modules' || 246 | (parts[parts.length - 3] === 'node_modules' && parts[parts.length - 2].startsWith('@')) 247 | ) 248 | ) { 249 | parts.pop() 250 | } 251 | return parts.join('/') 252 | } 253 | -------------------------------------------------------------------------------- /src/dispatcher.ts: -------------------------------------------------------------------------------- 1 | import { FORMAT_TEXT_MAP, Span, Tracer } from 'opentracing' 2 | import { ERROR } from 'opentracing/lib/ext/tags' 3 | import * as prometheus from 'prom-client' 4 | import { Observable, Subject } from 'rxjs' 5 | import { filter, map } from 'rxjs/operators' 6 | import { CancellationToken, CancellationTokenSource } from 'vscode-jsonrpc' 7 | import { 8 | ErrorCodes, 9 | isNotificationMessage, 10 | isRequestMessage, 11 | NotificationMessage, 12 | NotificationType1, 13 | RequestType1, 14 | ResponseMessage, 15 | } from 'vscode-jsonrpc/lib/messages' 16 | import { MessageReader, MessageWriter } from 'vscode-languageserver-protocol' 17 | import { isAbortError, tryCancel } from './cancellation' 18 | import { Logger } from './logging' 19 | import { logErrorEvent } from './tracing' 20 | 21 | interface Connection { 22 | reader: MessageReader 23 | writer: MessageWriter 24 | } 25 | 26 | type RequestId = string | number 27 | 28 | type RequestHandler = (params: P, token: CancellationToken, span: Span) => PromiseLike 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 23 | 24 | let progressIds = 1 25 | const createReporter = (connection: MessageConnection, logger: Logger, title?: string): ProgressReporter => { 26 | const id = String(progressIds++) 27 | const subject = new Subject() 28 | let didReport = false 29 | subject 30 | .pipe( 31 | // Convert a next() with percentage >= 100 to a complete() for safety 32 | // Apply this first because throttleTime() can drop emissions 33 | takeWhile(progress => !progress.percentage || progress.percentage < 100), 34 | // Merge progress updates with previous values because otherwise it would not be safe to throttle below (it may drop updates) 35 | // This way, every message contains the full state and does not depend on the previous state 36 | scan( 37 | (state, { percentage = state.percentage, message = state.message }) => ({ percentage, message }), 38 | {} 39 | ), 40 | distinctUntilChanged((a, b) => isEqual(a, b)), 41 | throttleTime(100, undefined, { leading: true, trailing: true }) 42 | ) 43 | .subscribe({ 44 | next: progress => { 45 | didReport = true 46 | tryLogError(logger, () => { 47 | connection.sendNotification(WindowProgressNotification.type, { 48 | ...progress, 49 | id, 50 | title, 51 | }) 52 | }) 53 | }, 54 | error: err => { 55 | tryLogError(logger, () => { 56 | // window/progress doesn't support erroring the progress, 57 | // but we can emulate by hiding the progress and showing an error 58 | if (didReport) { 59 | connection.sendNotification(WindowProgressNotification.type, { id, done: true }) 60 | } 61 | connection.sendNotification(ShowMessageNotification.type, { 62 | message: err.message, 63 | type: MessageType.Error, 64 | }) 65 | }) 66 | }, 67 | complete: () => { 68 | if (!didReport) { 69 | return 70 | } 71 | tryLogError(logger, () => { 72 | connection.sendNotification(WindowProgressNotification.type, { id, percentage: 100, done: true }) 73 | }) 74 | }, 75 | }) 76 | return subject 77 | } 78 | 79 | /** 80 | * Creates a progress display with the given title, 81 | * then calls the function with a ProgressReporter. 82 | * Once the task finishes, completes the progress display. 83 | */ 84 | export type ProgressProvider = ( 85 | title: string | undefined, 86 | fn: (reporter: ProgressReporter) => Promise 87 | ) => Promise 88 | 89 | export const createProgressProvider = (connection: MessageConnection, logger: Logger): ProgressProvider => async ( 90 | title, 91 | fn 92 | ) => { 93 | const reporter = createReporter(connection, logger, title) 94 | try { 95 | const result = await fn(reporter) 96 | reporter.complete() 97 | return result 98 | } catch (err) { 99 | reporter.error(err) 100 | throw err 101 | } 102 | } 103 | 104 | export const noopProgressProvider: ProgressProvider = (_title, fn) => fn(new Subject()) 105 | -------------------------------------------------------------------------------- /src/protocol.progress.proposed.ts: -------------------------------------------------------------------------------- 1 | /* -------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | * ------------------------------------------------------------------------------------------ */ 5 | 'use strict' 6 | 7 | import { NotificationHandler, NotificationType } from 'vscode-jsonrpc' 8 | 9 | export interface WindowProgressClientCapabilities { 10 | /** 11 | * Experimental client capabilities. 12 | */ 13 | experimental: { 14 | /** 15 | * The client has support for reporting progress. 16 | */ 17 | progress?: boolean 18 | } 19 | } 20 | 21 | export interface ProgressParams { 22 | /** 23 | * A unique identifier to associate multiple progress notifications with the same progress. 24 | */ 25 | id: string 26 | 27 | /** 28 | * Optional title of the progress. 29 | * If unset, the previous title (if any) is still valid. 30 | */ 31 | title?: string 32 | 33 | /** 34 | * Optional progress message to display. 35 | * If unset, the previous progress message (if any) is still valid. 36 | */ 37 | message?: string 38 | 39 | /** 40 | * Optional progress percentage to display (value 1 is considered 1%). 41 | * If unset, the previous progress percentage (if any) is still valid. 42 | */ 43 | percentage?: number 44 | 45 | /** 46 | * Set to true on the final progress update. 47 | * No more progress notifications with the same ID should be sent. 48 | */ 49 | done?: boolean 50 | } 51 | 52 | /** 53 | * The `window/progress` notification is sent from the server to the client 54 | * to ask the client to indicate progress. 55 | */ 56 | export namespace WindowProgressNotification { 57 | export const type = new NotificationType('window/progress') 58 | export type HandlerSignature = NotificationHandler 59 | } 60 | -------------------------------------------------------------------------------- /src/resources.ts: -------------------------------------------------------------------------------- 1 | import * as glob from 'fast-glob' 2 | import got from 'got' 3 | import { exists, readFile } from 'mz/fs' 4 | import { FORMAT_HTTP_HEADERS, Span, Tracer } from 'opentracing' 5 | import { fileURLToPath, pathToFileURL, URL } from 'url' 6 | 7 | interface GlobOptions { 8 | ignore?: string[] 9 | span?: Span 10 | tracer?: Tracer 11 | } 12 | 13 | export interface ResourceRetriever { 14 | /** The URI protocols (including trailing colon) this ResourceRetriever can handle */ 15 | readonly protocols: ReadonlySet 16 | 17 | /** 18 | * Checks if given resource exits and returns a boolean. 19 | */ 20 | exists(resource: URL, options?: { span?: Span; tracer?: Tracer }): Promise 21 | 22 | /** 23 | * Fetches the content of the resource and returns it as an UTF8 string. 24 | * If the resource does not exist, will reject with a `ResourceNotFoundError`. 25 | */ 26 | fetch(resource: URL, options?: { span?: Span; tracer?: Tracer }): Promise 27 | 28 | /** 29 | * Finds resources (files and directories) by a glob pattern URL. 30 | * Directory URLs are suffixed with a trailing slash. 31 | * 32 | * @param pattern 33 | * @returns Matching absolute URLs 34 | */ 35 | glob(pattern: URL, options?: GlobOptions): AsyncIterable 36 | } 37 | 38 | export class ResourceNotFoundError extends Error { 39 | public readonly name = 'ResourceNotFoundError' 40 | constructor(public readonly resource: URL) { 41 | super(`Resource not found: ${resource}`) 42 | } 43 | } 44 | 45 | /** 46 | * Can retrieve a file: resource 47 | */ 48 | export class FileResourceRetriever implements ResourceRetriever { 49 | public readonly protocols = new Set(['file:']) 50 | 51 | public async *glob(pattern: URL, { ignore = [] }: GlobOptions = {}): AsyncIterable { 52 | const files = glob.stream(fileURLToPath(pattern), { 53 | ignore, 54 | absolute: true, 55 | markDirectories: true, 56 | onlyFiles: false, 57 | }) 58 | // tslint:disable-next-line: await-promise https://github.com/palantir/tslint/issues/3997 59 | for await (const file of files) { 60 | yield pathToFileURL(file as string) 61 | } 62 | } 63 | 64 | public async exists(resource: URL): Promise { 65 | return await exists(fileURLToPath(resource)) 66 | } 67 | 68 | public async fetch(resource: URL): Promise { 69 | try { 70 | return await readFile(fileURLToPath(resource), 'utf-8') 71 | } catch (err) { 72 | if (err.code === 'ENOENT') { 73 | throw new ResourceNotFoundError(resource) 74 | } 75 | throw err 76 | } 77 | } 78 | } 79 | 80 | const USER_AGENT = 'TypeScript language server' 81 | 82 | /** 83 | * Can retrieve an http(s): resource 84 | */ 85 | export class HttpResourceRetriever implements ResourceRetriever { 86 | public readonly protocols = new Set(['http:', 'https:']) 87 | public glob(pattern: URL): AsyncIterable { 88 | throw new Error('Globbing is not implemented over HTTP') 89 | // const response = await got.get(pattern, { 90 | // headers: { 91 | // Accept: 'text/plain', 92 | // 'User-Agent': USER_AGENT, 93 | // }, 94 | // }) 95 | // Post-filter ignore pattern in case server does not support it 96 | // return response.body.split('\n').map(url => new URL(url, pattern)) 97 | } 98 | 99 | public async exists( 100 | resource: URL, 101 | { span = new Span(), tracer = new Tracer() }: { span?: Span; tracer?: Tracer } = {} 102 | ): Promise { 103 | try { 104 | const headers = { 105 | 'User-Agent': USER_AGENT, 106 | } 107 | tracer.inject(span, FORMAT_HTTP_HEADERS, headers) 108 | await got.head(resource, { headers }) 109 | return true 110 | } catch (err) { 111 | if (err.statusCode === 404) { 112 | return false 113 | } 114 | throw err 115 | } 116 | } 117 | 118 | public async fetch( 119 | resource: URL, 120 | { span = new Span(), tracer = new Tracer() }: { span?: Span; tracer?: Tracer } = {} 121 | ): Promise { 122 | try { 123 | const headers = { 124 | Accept: 'text/plain', 125 | 'User-Agent': USER_AGENT, 126 | } 127 | tracer.inject(span, FORMAT_HTTP_HEADERS, headers) 128 | const response = await got.get(resource, { headers }) 129 | return response.body 130 | } catch (err) { 131 | if (err.statusCode === 404) { 132 | throw new ResourceNotFoundError(resource) 133 | } 134 | throw err 135 | } 136 | } 137 | } 138 | 139 | export type ResourceRetrieverPicker = (uri: URL) => ResourceRetriever 140 | 141 | export function createResourceRetrieverPicker(retrievers: ResourceRetriever[]): ResourceRetrieverPicker { 142 | return uri => { 143 | const retriever = retrievers.find(retriever => retriever.protocols.has(uri.protocol)) 144 | if (!retriever) { 145 | throw new Error(`Unsupported protocol ${uri}`) 146 | } 147 | return retriever 148 | } 149 | } 150 | 151 | /** 152 | * Walks through the parent directories of a given URI. 153 | * Starts with the directory of the start URI (or the start URI itself if it is a directory). 154 | * Yielded directories will always have a trailing slash. 155 | */ 156 | export function* walkUp(start: URL): Iterable { 157 | let current = new URL('.', start) 158 | while (true) { 159 | yield current 160 | const parent = new URL('..', current) 161 | if (parent.href === current.href) { 162 | // Reached root 163 | return 164 | } 165 | current = parent 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import 'source-map-support/register' 2 | 3 | // Polyfill 4 | import { AbortController } from 'abort-controller' 5 | Object.assign(global, { AbortController }) 6 | 7 | import axios from 'axios' 8 | import express from 'express' 9 | import { highlight } from 'highlight.js' 10 | import * as http from 'http' 11 | import * as https from 'https' 12 | import * as ini from 'ini' 13 | import 'ix' 14 | import { from, merge } from 'ix/asynciterable' 15 | import { from as iterableFrom, IterableX } from 'ix/iterable' 16 | import { initTracerFromEnv } from 'jaeger-client' 17 | import { noop } from 'lodash' 18 | import mkdirp from 'mkdirp-promise' 19 | import * as fs from 'mz/fs' 20 | import { FORMAT_HTTP_HEADERS, Span, Tracer } from 'opentracing' 21 | import { HTTP_URL, SPAN_KIND, SPAN_KIND_RPC_CLIENT, SPAN_KIND_RPC_SERVER } from 'opentracing/lib/ext/tags' 22 | import { tmpdir } from 'os' 23 | import * as path from 'path' 24 | import prettyBytes from 'pretty-bytes' 25 | import * as prometheus from 'prom-client' 26 | import rmfr from 'rmfr' 27 | import { interval, Unsubscribable } from 'rxjs' 28 | import { NullableMappedPosition, RawSourceMap, SourceMapConsumer } from 'source-map' 29 | import { extract, FileStat } from 'tar' 30 | import * as type from 'type-is' 31 | import { fileURLToPath, pathToFileURL, URL } from 'url' 32 | import { inspect } from 'util' 33 | import * as uuid from 'uuid' 34 | import { 35 | CancellationToken, 36 | ClientCapabilities, 37 | CodeActionParams, 38 | CodeActionRequest, 39 | Definition, 40 | DefinitionRequest, 41 | DidOpenTextDocumentNotification, 42 | DidOpenTextDocumentParams, 43 | HoverRequest, 44 | ImplementationRequest, 45 | InitializeParams, 46 | InitializeRequest, 47 | InitializeResult, 48 | Location, 49 | LocationLink, 50 | PublishDiagnosticsNotification, 51 | PublishDiagnosticsParams, 52 | Range, 53 | ReferencesRequest, 54 | TextDocumentPositionParams, 55 | TypeDefinitionRequest, 56 | } from 'vscode-languageserver-protocol' 57 | import { createMessageConnection, IWebSocket, WebSocketMessageReader, WebSocketMessageWriter } from 'vscode-ws-jsonrpc' 58 | import * as rpcServer from 'vscode-ws-jsonrpc/lib/server' 59 | import { Server } from 'ws' 60 | import { throwIfCancelled, toAxiosCancelToken } from './cancellation' 61 | import { Settings } from './config' 62 | import { 63 | cloneUrlFromPackageMeta, 64 | fetchPackageMeta, 65 | filterDependencies, 66 | findClosestPackageJson, 67 | findPackageRootAndName, 68 | readPackageJson, 69 | resolveDependencyRootDir, 70 | } from './dependencies' 71 | import { createDispatcher, createRequestDurationMetric, RequestType } from './dispatcher' 72 | import { AsyncDisposable, Disposable, disposeAllAsync } from './disposable' 73 | import { resolveRepository } from './graphql' 74 | import { flatMapConcurrent } from './ix' 75 | import { LanguageServer, spawnLanguageServer } from './language-server' 76 | import { Logger, LSPLogger, MultiLogger, PrefixedLogger, redact, RedactingLogger } from './logging' 77 | import { createProgressProvider, noopProgressProvider, ProgressProvider } from './progress' 78 | import { WindowProgressClientCapabilities } from './protocol.progress.proposed' 79 | import { 80 | createResourceRetrieverPicker, 81 | FileResourceRetriever, 82 | HttpResourceRetriever, 83 | ResourceNotFoundError, 84 | walkUp, 85 | } from './resources' 86 | import { tracePromise } from './tracing' 87 | import { sanitizeTsConfigs } from './tsconfig' 88 | import { relativeUrl, URLMap, URLSet } from './uri' 89 | import { install } from './yarn' 90 | 91 | const globalLogger = new RedactingLogger(console) 92 | 93 | process.on('uncaughtException', err => { 94 | globalLogger.error('Uncaught exception:', err) 95 | process.exit(1) 96 | }) 97 | 98 | const CACHE_DIR = process.env.CACHE_DIR || fs.realpathSync(tmpdir()) 99 | globalLogger.log(`Using CACHE_DIR ${CACHE_DIR}`) 100 | 101 | const tracer = initTracerFromEnv({ serviceName: 'lang-typescript' }, { logger: globalLogger }) 102 | 103 | const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080 104 | 105 | let httpServer: http.Server | https.Server 106 | if (process.env.TLS_CERT && process.env.TLS_KEY) { 107 | globalLogger.log('TLS encryption enabled') 108 | httpServer = https.createServer({ 109 | cert: process.env.TLS_CERT, 110 | key: process.env.TLS_KEY, 111 | }) 112 | } else { 113 | httpServer = http.createServer() 114 | } 115 | 116 | /** Disposables to be disposed when the whole server is shutting down */ 117 | const globalDisposables = new Set() 118 | 119 | // Cleanup when receiving signals 120 | for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM'] as NodeJS.Signals[]) { 121 | process.once(signal, async () => { 122 | globalLogger.log(`Received ${signal}, cleaning up`) 123 | await disposeAllAsync(globalDisposables) 124 | process.exit(0) 125 | }) 126 | } 127 | 128 | const webSocketServer = new Server({ server: httpServer }) 129 | globalDisposables.add({ dispose: () => webSocketServer.close() }) 130 | 131 | const openConnectionsMetric = new prometheus.Gauge({ 132 | name: 'typescript_open_websocket_connections', 133 | help: 'Open WebSocket connections to the TypeScript server', 134 | }) 135 | const requestDurationMetric = createRequestDurationMetric() 136 | prometheus.collectDefaultMetrics() 137 | 138 | const isTypeScriptFile = (path: string): boolean => /((\.d)?\.[tj]sx?|json)$/.test(path) 139 | 140 | const pickResourceRetriever = createResourceRetrieverPicker([new HttpResourceRetriever(), new FileResourceRetriever()]) 141 | 142 | const TYPESCRIPT_DIR_URI = pathToFileURL(path.resolve(__dirname, '..', 'node_modules', 'typescript') + '/') 143 | const TYPESCRIPT_VERSION = JSON.parse( 144 | fs.readFileSync(path.resolve(__dirname, '..', 'node_modules', 'typescript', 'package.json'), 'utf-8') 145 | ).version 146 | globalLogger.log(`Using TypeScript version ${TYPESCRIPT_VERSION} from ${TYPESCRIPT_DIR_URI}`) 147 | 148 | webSocketServer.on('connection', connection => { 149 | const connectionId = uuid.v1() 150 | openConnectionsMetric.set(webSocketServer.clients.size) 151 | globalLogger.log(`New WebSocket connection ${connectionId}, ${webSocketServer.clients.size} open`) 152 | 153 | /** Functions to run when this connection is closed (or the server shuts down) */ 154 | const connectionDisposables = new Set() 155 | { 156 | const connectionDisposable: AsyncDisposable = { 157 | disposeAsync: async () => await disposeAllAsync([...connectionDisposables].reverse()), 158 | } 159 | globalDisposables.add(connectionDisposable) 160 | connectionDisposables.add({ dispose: () => globalDisposables.delete(connectionDisposable) }) 161 | const closeListener = async (code: number, reason: string) => { 162 | openConnectionsMetric.set(webSocketServer.clients.size) 163 | globalLogger.log(`WebSocket closed: ${connectionId}, ${webSocketServer.clients.size} open`, { 164 | code, 165 | reason, 166 | }) 167 | await connectionDisposable.disposeAsync() 168 | } 169 | connection.on('close', closeListener) 170 | connectionDisposables.add({ dispose: () => connection.removeListener('close', closeListener) }) 171 | } 172 | 173 | const webSocket: IWebSocket = { 174 | onMessage: handler => connection.on('message', handler), 175 | onClose: handler => connection.on('close', handler), 176 | onError: handler => connection.on('error', handler), 177 | send: content => connection.send(content), 178 | dispose: () => connection.close(), 179 | } 180 | connectionDisposables.add(webSocket) 181 | const webSocketReader = new WebSocketMessageReader(webSocket) 182 | connectionDisposables.add(webSocketReader) 183 | const webSocketWriter = new WebSocketMessageWriter(webSocket) 184 | connectionDisposables.add(webSocketWriter) 185 | const webSocketConnection = rpcServer.createConnection(webSocketReader, webSocketWriter, noop) 186 | const webSocketMessageConnection = createMessageConnection( 187 | webSocketConnection.reader, 188 | webSocketConnection.writer, 189 | globalLogger 190 | ) 191 | const logger: Logger = new PrefixedLogger( 192 | new MultiLogger([globalLogger, new RedactingLogger(new LSPLogger(webSocketMessageConnection))]), 193 | `conn ${connectionId}` 194 | ) 195 | const connectionLogger = logger 196 | 197 | // Periodically send ping/pong messages 198 | // to check if connection is still alive 199 | let alive = true 200 | connection.on('pong', () => { 201 | logger.log('Got pong') 202 | alive = true 203 | }) 204 | logger.log('WebSocket open') 205 | connectionDisposables.add( 206 | interval(30000).subscribe(() => { 207 | try { 208 | if (!alive) { 209 | logger.log('Terminating WebSocket') 210 | connection.terminate() 211 | } 212 | alive = false 213 | if (connection.readyState === connection.OPEN) { 214 | connection.ping() 215 | } 216 | } catch (err) { 217 | logger.error('Ping error', err) 218 | } 219 | }) 220 | ) 221 | connection.ping() 222 | 223 | // Connection state set on initialize 224 | let languageServer: LanguageServer 225 | /** The initialize params passed to the typescript language server */ 226 | let serverInitializeParams: InitializeParams 227 | let configuration: Settings 228 | let tempDir: string 229 | let tempDirUri: URL 230 | let httpRootUri: URL 231 | let fileRootUri: URL 232 | let extractPath: string 233 | let tsserverCacheDir: string 234 | // yarn folders 235 | let globalFolderRoot: string 236 | let cacheFolderRoot: string 237 | /** HTTP URIs for directories in the workspace that contain a package.json (and are not inside node_modules) */ 238 | let packageRootUris: URLSet 239 | /** Map from HTTP URI for directory of package.json to Promise for its installation */ 240 | const dependencyInstallationPromises = new URLMap>() 241 | /** HTTP URIs of directories with package.jsons in the workspace that finished installation */ 242 | const finishedDependencyInstallations = new URLSet() 243 | /** Map from file URIs of text documents that were sent didOpen for to mapped TextDocumentDidOpenParams */ 244 | const openTextDocuments = new URLMap() 245 | 246 | const dispatcher = createDispatcher(webSocketConnection, { 247 | requestDurationMetric, 248 | logger, 249 | tracer, 250 | tags: { 251 | connectionId, 252 | [SPAN_KIND]: SPAN_KIND_RPC_SERVER, 253 | }, 254 | }) 255 | connectionDisposables.add({ dispose: () => dispatcher.dispose() }) 256 | 257 | let withProgress: ProgressProvider = noopProgressProvider 258 | 259 | /** Checks if the given URI is under the root URI */ 260 | const isInWorkspace = (resource: URL): boolean => resource.href.startsWith(httpRootUri.href) 261 | 262 | /** 263 | * Maps TextDocumentPositionParams with a http URI to one with a file URI. 264 | * If the http URI is out-of-workspace (ouside the rootUri), it attempts to map it to a file: URI within node_modules. 265 | * 266 | * @param incomingUri Example: `https://accesstoken@sourcegraph.com/github.com/sourcegraph/extensions-client-common@80389224bd48e1e696d5fa11b3ec6fba341c695b/-/raw/src/schema/graphqlschema.ts` 267 | */ 268 | async function mapTextDocumentPositionParams( 269 | params: TextDocumentPositionParams, 270 | { span, token }: { span: Span; token: CancellationToken } 271 | ): Promise { 272 | return await tracePromise('Map parameters to file location', tracer, span, async span => { 273 | throwIfCancelled(token) 274 | const incomingUri = new URL(params.textDocument.uri) 275 | if (isInWorkspace(incomingUri)) { 276 | // In-workspace URI, do a simple rewrite from http to file URI 277 | return { 278 | textDocument: { 279 | uri: mapHttpToFileUrlSimple(incomingUri).href, 280 | }, 281 | position: params.position, 282 | } 283 | } 284 | 285 | // URI is an out-of-workspace URI (a URI from a different project) 286 | // This external project may exist in the form of a dependency in node_modules 287 | // Find the closest package.json to it to figure out the package name 288 | const [packageRoot, packageName] = await findPackageRootAndName(incomingUri, pickResourceRetriever, { 289 | span, 290 | tracer, 291 | }) 292 | // Run yarn install for all package.jsons that contain the dependency we are looking for 293 | logger.log(`Installing dependencies for all package.jsons that depend on "${packageName}"`) 294 | await Promise.all( 295 | iterableFrom(packageRootUris).map(async packageRootUri => { 296 | const pkgJsonUri = new URL('package.json', packageRootUri) 297 | const pkgJson = await readPackageJson(pkgJsonUri, pickResourceRetriever, { span, tracer }) 298 | if ( 299 | (pkgJson.dependencies && pkgJson.dependencies.hasOwnProperty(packageName)) || 300 | (pkgJson.devDependencies && pkgJson.devDependencies.hasOwnProperty(packageName)) 301 | ) { 302 | logger.log(`package.json at ${packageRootUri} has dependency on "${packageName}", installing`) 303 | await Promise.all([ 304 | ensureDependenciesForPackageRoot(packageRootUri, { tracer, span, token }), 305 | (async () => { 306 | const filePackageRoot = mapHttpToFileUrlSimple(packageRootUri) 307 | // tsserver is unable to find references from projects that don't have at least one file open. 308 | // To find global references to a file in node_modules, open one random TypeScript file 309 | // of every tsconfig.json containing or contained by a package depending on the package 310 | const tsconfigPattern = new URL('**/tsconfig.json', filePackageRoot) 311 | const projectRoots = merge( 312 | // Find any tsconfig in child directories of the package root (and the package root itself) 313 | pickResourceRetriever(tsconfigPattern).glob(tsconfigPattern, { 314 | ignore: ['**/node_modules'], 315 | }), 316 | // Find any tsconfig in parent directories of the package root 317 | from(walkUp(filePackageRoot)).filter(async dir => { 318 | const tsconfigUri = new URL('tsconfig.json', dir) 319 | return await pickResourceRetriever(tsconfigUri).exists(tsconfigUri) 320 | }) 321 | ) 322 | await flatMapConcurrent(projectRoots, 10, async function*( 323 | projectRoot 324 | ): AsyncGenerator { 325 | const pattern = new URL('**/*.ts?(x)', projectRoot) 326 | // Find a random TS file in the project and open it 327 | const typescriptFile = await from( 328 | pickResourceRetriever(pattern).glob(pattern) 329 | ).first() 330 | logger.log(`Opening ${typescriptFile} to trigger project load of ${projectRoot}`) 331 | if (typescriptFile) { 332 | await openTextDocument(typescriptFile) 333 | } 334 | }).toArray() 335 | })(), 336 | ]) 337 | } 338 | }) 339 | ) 340 | 341 | const packageRootRelativePath = relativeUrl(packageRoot, incomingUri) 342 | 343 | // Check if the file already exists somewhere in node_modules 344 | // This is the case for non-generated declaration files (including @types/ packages) and packages that ship sources (e.g. ix) 345 | { 346 | const patternUrl = new URL( 347 | path.posix.join(`**/node_modules/${packageName}`, packageRootRelativePath), 348 | tempDirUri 349 | ) 350 | const file = await from( 351 | pickResourceRetriever(patternUrl).glob(patternUrl, { 352 | span, 353 | tracer, 354 | }) 355 | ).first() 356 | if (file) { 357 | const mappedParams = { 358 | position: params.position, 359 | textDocument: { 360 | uri: file.href, 361 | }, 362 | } 363 | logger.log(`Found file ${incomingUri} in node_modules at ${file}`) 364 | logger.log('Mapped params', params, 'to', mappedParams) 365 | return mappedParams 366 | } 367 | } 368 | 369 | // If the incoming URI is already a declaration file, abort 370 | if (incomingUri.pathname.endsWith('.d.ts')) { 371 | throw new Error(`Incoming declaration file ${incomingUri} does not exist in workspace's node_modules`) 372 | } 373 | // If the incoming URI is not a declaration file and does not exist in node_modules, 374 | // it is a source file that needs to be mapped to a declaration file using a declaration map 375 | // Find all .d.ts.map files in the package 376 | logger.log( 377 | `Looking for declaration maps to map source file ${incomingUri} to declaration file in node_modules` 378 | ) 379 | const patternUrl = new URL(`**/node_modules/${packageName}/**/*.d.ts.map`, tempDirUri) 380 | const declarationMapUrls = from(pickResourceRetriever(patternUrl).glob(patternUrl, { span, tracer })) 381 | const mappedParams = await flatMapConcurrent(declarationMapUrls, 10, async function*(declarationMapUrl) { 382 | try { 383 | const declarationMap: RawSourceMap = JSON.parse( 384 | await pickResourceRetriever(declarationMapUrl).fetch(declarationMapUrl, { span, tracer }) 385 | ) 386 | const packageRootPath = resolveDependencyRootDir(fileURLToPath(declarationMapUrl)) 387 | const packageRootFileUrl = pathToFileURL(packageRootPath + '/') 388 | const sourceFileUrl = new URL(packageRootRelativePath, packageRootFileUrl) 389 | // Check if any of the sources of this source file matches the source file we are looking for 390 | if ( 391 | !declarationMap.sources.some( 392 | source => new URL(source, declarationMapUrl).href === sourceFileUrl.href 393 | ) 394 | ) { 395 | return 396 | } 397 | logger.log(`Declaration map ${declarationMapUrl} matches source ${sourceFileUrl}`) 398 | const declarationFile = new URL(declarationMap.file, declarationMapUrl) 399 | // Use the source map to match the location in the source file to the location in the .d.ts file 400 | const consumer = await new SourceMapConsumer(declarationMap, declarationMapUrl.href) 401 | try { 402 | const declarationPosition = consumer.generatedPositionFor({ 403 | source: sourceFileUrl.href, 404 | // LSP is 0-based, source maps are 1-based line numbers 405 | line: params.position.line + 1, 406 | column: params.position.character, 407 | }) 408 | if (declarationPosition.line === null || declarationPosition.column === null) { 409 | const { line, character } = params.position 410 | throw new Error( 411 | `Could not map source position ${sourceFileUrl}:${line}:${character} to position in declaration file` 412 | ) 413 | } 414 | yield { 415 | textDocument: { 416 | uri: declarationFile.href, 417 | }, 418 | position: { 419 | line: declarationPosition.line - 1, 420 | character: declarationPosition.column, 421 | }, 422 | } 423 | } finally { 424 | consumer.destroy() 425 | } 426 | } catch (err) { 427 | logger.error(`Error processing declaration map ${declarationMapUrl}`, err) 428 | } 429 | }).first() 430 | if (!mappedParams) { 431 | throw new Error(`Could not find out-of-workspace URI ${incomingUri} in workspace's dependencies`) 432 | } 433 | return mappedParams 434 | }) 435 | } 436 | function mapHttpToFileUrlSimple(uri: URL): URL { 437 | const relative = relativeUrl(httpRootUri, uri) 438 | const fileUri = new URL(relative, fileRootUri.href) 439 | // Security check to prevent access from one connection into 440 | // other files on the container or other connection's directories 441 | if (!fileUri.href.startsWith(fileRootUri.href)) { 442 | throw new Error(`URI ${uri} is not under rootUri ${httpRootUri}`) 443 | } 444 | return fileUri 445 | } 446 | /** 447 | * Converts the given `file:` URI to an HTTP URI rooted at the `rootUri`. 448 | * 449 | * @throws If resource is in node_modules 450 | */ 451 | function mapFileToHttpUrlSimple(uri: URL): URL { 452 | const relativePath = relativeUrl(fileRootUri, uri) 453 | if (relativePath.includes('node_modules/')) { 454 | throw new Error(`Can't map URI ${uri} to HTTP URL because it is in node_modules`) 455 | } 456 | const httpUri = new URL(relativePath, httpRootUri.href) 457 | if (!httpUri.href.startsWith(httpRootUri.href)) { 458 | // Should never happen, since these are outgoing URIs 459 | // Sanity check against bugs (e.g. not realpath()ing the temp dir) 460 | throw new Error(`URI ${httpUri} is not under rootUri ${httpRootUri}`) 461 | } 462 | return httpUri 463 | } 464 | 465 | // tsserver often doesn't properly catch all files added by dependency installation. 466 | // For safety, we restart it after dependencies were installed. 467 | async function restartLanguageServer({ 468 | span, 469 | token, 470 | }: { 471 | span: Span 472 | token: CancellationToken 473 | }): Promise { 474 | // Kill old language server instance 475 | if (languageServer) { 476 | connectionDisposables.delete(languageServer) 477 | languageServer.dispose() 478 | } 479 | languageServer = await spawnLanguageServer({ 480 | tempDir, 481 | tsserverCacheDir, 482 | configuration, 483 | connectionId, 484 | tracer, 485 | logger, 486 | }) 487 | connectionDisposables.add(languageServer) 488 | connectionDisposables.add( 489 | languageServer.errors.subscribe(err => { 490 | logger.error('Launching language server failed', err) 491 | connection.close() 492 | }) 493 | ) 494 | // Forward diagnostics 495 | if (configuration['typescript.diagnostics.enable']) { 496 | connectionDisposables.add( 497 | languageServer.dispatcher.observeNotification(PublishDiagnosticsNotification.type).subscribe(params => { 498 | try { 499 | if (params.uri.includes('/node_modules/')) { 500 | return 501 | } 502 | const mappedParams: PublishDiagnosticsParams = { 503 | ...params, 504 | uri: mapFileToHttpUrlSimple(new URL(params.uri)).href, 505 | } 506 | webSocketMessageConnection.sendNotification(PublishDiagnosticsNotification.type, mappedParams) 507 | } catch (err) { 508 | logger.error( 509 | `Error handling ${PublishDiagnosticsNotification.type.method} notification`, 510 | params, 511 | err 512 | ) 513 | } 514 | }) 515 | ) 516 | } 517 | // Initialize it again with same InitializeParams 518 | const initializeResult = await sendServerRequest(InitializeRequest.type, serverInitializeParams, { 519 | tracer, 520 | span, 521 | token, 522 | }) 523 | // Replay didOpen notifications 524 | for (const didOpenParams of openTextDocuments.values()) { 525 | languageServer.connection.sendNotification(DidOpenTextDocumentNotification.type, didOpenParams) 526 | } 527 | return initializeResult 528 | } 529 | 530 | dispatcher.setRequestHandler(InitializeRequest.type, async (params, token, span) => { 531 | if (!params.rootUri) { 532 | throw new Error('No rootUri given as initialize parameter') 533 | } 534 | logger.log(`rootUri ${params.rootUri}`) 535 | if (params.workspaceFolders && params.workspaceFolders.length > 1) { 536 | throw new Error( 537 | 'More than one workspace folder given. The TypeScript server only supports a single workspace folder.' 538 | ) 539 | } 540 | httpRootUri = new URL(params.rootUri) 541 | span.setTag('rootUri', httpRootUri.href) 542 | if (httpRootUri.protocol !== 'http:' && httpRootUri.protocol !== 'https:') { 543 | throw new Error('rootUri protocol must be http or https, got ' + httpRootUri) 544 | } 545 | 546 | // Workaround until workspace/configuration is allowed during initialize 547 | if (params.initializationOptions && params.initializationOptions.configuration) { 548 | configuration = params.initializationOptions.configuration 549 | } 550 | 551 | const capabilities = params.capabilities as ClientCapabilities & WindowProgressClientCapabilities 552 | if ( 553 | capabilities.experimental && 554 | capabilities.experimental.progress && 555 | configuration['typescript.progress'] !== false 556 | ) { 557 | // Client supports reporting progress 558 | withProgress = createProgressProvider(webSocketMessageConnection, logger) 559 | } 560 | 561 | // Create temp folders 562 | tempDir = path.join(CACHE_DIR, connectionId) 563 | tempDirUri = pathToFileURL(tempDir + '/') 564 | await mkdirp(tempDir) 565 | connectionDisposables.add({ 566 | disposeAsync: async () => { 567 | globalLogger.log('Deleting temp dir ', tempDir) 568 | await rmfr(tempDir) 569 | }, 570 | }) 571 | extractPath = path.join(tempDir, 'repo') 572 | cacheFolderRoot = path.join(tempDir, 'cache') 573 | globalFolderRoot = path.join(tempDir, 'global') 574 | tsserverCacheDir = path.join(tempDir, 'tsserver_cache') 575 | await Promise.all([ 576 | fs.mkdir(tsserverCacheDir), 577 | fs.mkdir(extractPath), 578 | fs.mkdir(cacheFolderRoot), 579 | fs.mkdir(globalFolderRoot), 580 | (async () => { 581 | if (configuration['typescript.npmrc']) { 582 | await fs.writeFile(path.join(tempDir, '.npmrc'), ini.stringify(configuration['typescript.npmrc'])) 583 | } 584 | })(), 585 | ]) 586 | 587 | // Fetch tar and extract into temp folder 588 | /** Detected paths to package.jsons (that are not in node_modules) */ 589 | const packageJsonPaths: string[] = [] 590 | logger.info('Fetching archive from', httpRootUri.href) 591 | logger.log('Extracting to', extractPath) 592 | await tracePromise('Fetch source archive', tracer, span, async span => { 593 | await withProgress('Loading TypeScript project', async reporter => { 594 | span.setTag(HTTP_URL, redact(httpRootUri.href)) 595 | const headers = { 596 | Accept: 'application/x-tar', 597 | 'User-Agent': 'TypeScript language server', 598 | } 599 | span.tracer().inject(span, FORMAT_HTTP_HEADERS, headers) 600 | const response = await axios.get(httpRootUri.href, { 601 | headers, 602 | responseType: 'stream', 603 | cancelToken: toAxiosCancelToken(token), 604 | }) 605 | const contentType = response.headers['content-type'] 606 | if (!type.is(contentType, 'application/*')) { 607 | throw new Error(`Expected response to be of content type application/x-tar, was ${contentType}`) 608 | } 609 | const contentLength: number | undefined = 610 | response.headers['content-length'] && +response.headers['content-length'] 611 | logger.log('Archive size:', contentLength && prettyBytes(contentLength)) 612 | let bytes = 0 613 | await new Promise((resolve, reject) => { 614 | response.data 615 | .on('error', reject) 616 | .on('data', (chunk: Buffer) => { 617 | bytes += chunk.byteLength 618 | if (contentLength) { 619 | reporter.next({ percentage: bytes / contentLength }) 620 | } 621 | }) 622 | .pipe(extract({ cwd: extractPath, filter: isTypeScriptFile })) 623 | .on('entry', (entry: FileStat) => { 624 | if ( 625 | entry.header.path && 626 | entry.header.path.endsWith('package.json') && 627 | // Make sure to not capture package.json inside checked-in node_modules 628 | !entry.header.path.split('/').includes('node_modules') 629 | ) { 630 | packageJsonPaths.push(entry.header.path) 631 | } 632 | }) 633 | .on('warn', warning => logger.warn(warning)) 634 | .on('finish', resolve) 635 | .on('error', reject) 636 | }) 637 | span.setTag('bytes', bytes) 638 | }) 639 | }) 640 | 641 | // Find package.jsons to install 642 | throwIfCancelled(token) 643 | logger.log('package.jsons found:', packageJsonPaths) 644 | packageRootUris = new URLSet( 645 | packageJsonPaths.map(packageJsonPath => new URL(path.dirname(packageJsonPath) + '/', httpRootUri.href)) 646 | ) 647 | 648 | // Sanitize tsconfig.json files 649 | await sanitizeTsConfigs({ dir: pathToFileURL(extractPath), pickResourceRetriever, logger, tracer, span, token }) 650 | 651 | // The trailing slash is important for resolving URL relatively to it 652 | fileRootUri = pathToFileURL(extractPath + '/') 653 | // URIs are rewritten by rewriteUris below, but it doesn't touch rootPath 654 | serverInitializeParams = { ...params, rootPath: extractPath, rootUri: fileRootUri.href } 655 | 656 | // Spawn language server 657 | return await restartLanguageServer({ span, token }) 658 | }) 659 | 660 | /** 661 | * Returns all known package.json directories that are an ancestor of the given URI (and therefor should be installed to provide codeintel on this URI). 662 | * 663 | * @param uri The HTTP URL of a text document 664 | * @return HTTP URLs of package.json directories 665 | */ 666 | const findParentPackageRoots = (uri: URL): IterableX => 667 | iterableFrom(packageRootUris).filter(packageRoot => uri.href.startsWith(packageRoot.href)) 668 | 669 | async function installDependenciesForPackage( 670 | packageRootUri: URL, 671 | { tracer, span, token }: { tracer: Tracer; span?: Span; token: CancellationToken } 672 | ): Promise { 673 | await tracePromise('Install dependencies for package', tracer, span, async span => { 674 | span.setTag('packageRoot', packageRootUri) 675 | const relPackageRoot = relativeUrl(httpRootUri, packageRootUri) 676 | const logger = new PrefixedLogger(connectionLogger, 'install ' + relPackageRoot) 677 | try { 678 | const absPackageJsonPath = path.join(extractPath, relPackageRoot, 'package.json') 679 | const npmConfig = configuration['typescript.npmrc'] || {} 680 | const hasDeps = await filterDependencies(absPackageJsonPath, { npmConfig, logger, tracer, span, token }) 681 | if (!hasDeps) { 682 | return 683 | } 684 | // It's important that each concurrent yarn process has their own global and cache folders 685 | const globalFolder = path.join(globalFolderRoot, relPackageRoot) 686 | const cacheFolder = path.join(cacheFolderRoot, relPackageRoot) 687 | const cwd = path.join(extractPath, relPackageRoot) 688 | await Promise.all([mkdirp(path.join(globalFolder)), mkdirp(path.join(cacheFolder))]) 689 | await install({ cwd, globalFolder, cacheFolder, logger, tracer, span, token, withProgress }) 690 | await sanitizeTsConfigs({ 691 | dir: pathToFileURL(path.join(cwd, 'node_modules')), 692 | pickResourceRetriever, 693 | logger, 694 | tracer, 695 | span, 696 | token, 697 | }) 698 | if (configuration['typescript.restartAfterDependencyInstallation'] !== false) { 699 | await restartLanguageServer({ span, token }) 700 | } 701 | } catch (err) { 702 | throwIfCancelled(token) 703 | logger.error('Installation failed', err) 704 | } finally { 705 | finishedDependencyInstallations.add(packageRootUri) 706 | } 707 | }) 708 | } 709 | 710 | async function ensureDependenciesForPackageRoot( 711 | packageRootUri: URL, 712 | { tracer, span, token }: { tracer: Tracer; span?: Span; token: CancellationToken } 713 | ): Promise { 714 | let installationPromise = dependencyInstallationPromises.get(packageRootUri) 715 | if (!installationPromise) { 716 | installationPromise = installDependenciesForPackage(packageRootUri, { tracer, span, token }) 717 | // Save Promise so requests can wait for the installation to finish 718 | dependencyInstallationPromises.set(packageRootUri, installationPromise) 719 | } 720 | await installationPromise 721 | } 722 | 723 | /** 724 | * Ensures dependencies for all package.jsons in parent directories of the given text document were installed. 725 | * Errors will be caught and logged. 726 | * 727 | * @param textDocumentUri The HTTP text document URI that dependencies should be installed for 728 | * @throws never 729 | */ 730 | async function ensureDependenciesForDocument( 731 | textDocumentUri: URL, 732 | { tracer, span, token = CancellationToken.None }: { tracer: Tracer; span?: Span; token?: CancellationToken } 733 | ): Promise { 734 | await tracePromise('Ensure dependencies', tracer, span, async span => { 735 | throwIfCancelled(token) 736 | const parentPackageRoots = findParentPackageRoots(textDocumentUri) 737 | span.setTag('packageJsonLocations', parentPackageRoots.map(String)) 738 | logger.log(`Ensuring dependencies for text document ${textDocumentUri} defined in`, [ 739 | ...parentPackageRoots.map(String), 740 | ]) 741 | await Promise.all( 742 | parentPackageRoots.map(async packageRoot => { 743 | await ensureDependenciesForPackageRoot(packageRoot, { tracer, span, token }) 744 | }) 745 | ) 746 | }) 747 | } 748 | 749 | /** 750 | * Sends a request to the language server with support for OpenTracing (wrapping the request in a span) 751 | */ 752 | async function sendServerRequest( 753 | type: RequestType, 754 | params: P, 755 | { tracer, span, token }: { tracer: Tracer; span: Span; token: CancellationToken } 756 | ): Promise { 757 | return await tracePromise('Request ' + type.method, tracer, span, async span => { 758 | span.setTag(SPAN_KIND, SPAN_KIND_RPC_CLIENT) 759 | const result = await languageServer.connection.sendRequest(type, params, token) 760 | // logger.log(`Got result for ${type.method}`, params, result) 761 | return result 762 | }) 763 | } 764 | 765 | dispatcher.setRequestHandler(HoverRequest.type, async (params, token, span) => { 766 | // Map the http URI in params to file URIs 767 | const mappedParams = await mapTextDocumentPositionParams(params, { span, token }) 768 | const result = await sendServerRequest(HoverRequest.type, mappedParams, { token, tracer, span }) 769 | // Heuristic: If the hover contained an `any` type or shows the definition at the `import` line, 770 | // start dependency installation in the background 771 | // This is not done on file open because tsserver can get busy with handling all the file change events 772 | // It's expected that the client polls to get an updated hover content once dependency installation finished 773 | if (/\b(any|import)\b/.test(JSON.stringify(result))) { 774 | // tslint:disable-next-line:no-floating-promises 775 | ensureDependenciesForDocument(new URL(params.textDocument.uri), { tracer, span, token }) 776 | } 777 | return result 778 | }) 779 | 780 | /** 781 | * Maps Locations returned as a result from a definition, type definition, implementation or references call to HTTP URLs 782 | * and potentially to external repositories if the location is in node_modules. 783 | * 784 | * @param location A location on the file system (with a `file:` URI) 785 | */ 786 | async function mapFileLocation(location: Location, { token }: { token: CancellationToken }): Promise { 787 | const fileUri = new URL(location.uri) 788 | // Check if file path is in TypeScript lib 789 | // If yes, point to Microsoft/TypeScript GitHub repo 790 | if (fileUri.href.startsWith(TYPESCRIPT_DIR_URI.href)) { 791 | const relativeFilePath = relativeUrl(TYPESCRIPT_DIR_URI, fileUri) 792 | // TypeScript git tags their releases, but has no gitHead field. 793 | const typescriptUrl = new URL( 794 | `https://sourcegraph.com/github.com/Microsoft/TypeScript@v${TYPESCRIPT_VERSION}/-/raw/${relativeFilePath}` 795 | ) 796 | return { uri: typescriptUrl.href, range: location.range } 797 | } 798 | // Check if file path is inside a node_modules dir 799 | // If it is inside node_modules, that means the file is out-of-workspace, i.e. outside of the HTTP root URI 800 | // We return an HTTP URL to the client that the client can access 801 | if (fileUri.pathname.includes('/node_modules/')) { 802 | try { 803 | const [, packageJson] = await findClosestPackageJson(fileUri, pickResourceRetriever, tempDirUri) 804 | if (!packageJson.repository) { 805 | throw new Error(`Package ${packageJson.name} has no repository field`) 806 | } 807 | let cloneUrl = cloneUrlFromPackageMeta(packageJson) 808 | let subdir = '' 809 | // Handle GitHub tree URLs 810 | const treeMatch = cloneUrl.match( 811 | /^(?:https?:\/\/)?(?:www\.)?github.com\/[^\/]+\/[^\/]+\/tree\/[^\/]+\/(.+)$/ 812 | ) 813 | if (treeMatch) { 814 | subdir = treeMatch[1] 815 | cloneUrl = cloneUrl.replace(/(\/tree\/[^\/]+)\/.+/, '$1') 816 | } 817 | if (typeof packageJson.repository === 'object' && packageJson.repository.directory) { 818 | subdir = packageJson.repository.directory 819 | } else if (packageJson.name.startsWith('@types/')) { 820 | // Special-case DefinitelyTyped 821 | subdir = packageJson.name.substr(1) 822 | } 823 | const npmConfig = configuration['typescript.npmrc'] || {} 824 | const packageMeta = await fetchPackageMeta(packageJson.name, packageJson.version, npmConfig) 825 | 826 | // fileUri is usually a .d.ts file that does not exist in the repo, only in node_modules 827 | // Check if a source map exists to map it to the .ts source file that is checked into the repo 828 | let mappedUri: URL 829 | let mappedRange: Range 830 | try { 831 | const sourceMapUri = new URL(fileUri.href + '.map') 832 | const sourceMap = await pickResourceRetriever(sourceMapUri).fetch(sourceMapUri) 833 | const consumer = await new SourceMapConsumer(sourceMap, sourceMapUri.href) 834 | let mappedStart: NullableMappedPosition 835 | let mappedEnd: NullableMappedPosition 836 | try { 837 | mappedStart = consumer.originalPositionFor({ 838 | line: location.range.start.line + 1, 839 | column: location.range.start.character, 840 | }) 841 | mappedEnd = consumer.originalPositionFor({ 842 | line: location.range.end.line + 1, 843 | column: location.range.end.character, 844 | }) 845 | } finally { 846 | consumer.destroy() 847 | } 848 | if ( 849 | mappedStart.source === null || 850 | mappedStart.line === null || 851 | mappedStart.column === null || 852 | mappedEnd.line === null || 853 | mappedEnd.column === null 854 | ) { 855 | throw new Error('Could not map position') 856 | } 857 | mappedUri = new URL(mappedStart.source) 858 | if (!mappedUri.href.startsWith(tempDirUri.href)) { 859 | throw new Error( 860 | `Mapped source URI ${mappedUri} is not under root URI ${fileRootUri} and not in automatic typings` 861 | ) 862 | } 863 | mappedRange = { 864 | start: { 865 | line: mappedStart.line - 1, 866 | character: mappedStart.column, 867 | }, 868 | end: { 869 | line: mappedEnd.line - 1, 870 | character: mappedEnd.column, 871 | }, 872 | } 873 | } catch (err) { 874 | throwIfCancelled(token) 875 | if (err instanceof ResourceNotFoundError) { 876 | logger.log(`No declaration map for ${fileUri}, using declaration file`) 877 | } else { 878 | logger.error(`Source-mapping location failed`, location, err) 879 | } 880 | // If mapping failed, use the original file 881 | mappedUri = fileUri 882 | mappedRange = location.range 883 | } 884 | 885 | const depRootDir = resolveDependencyRootDir(fileURLToPath(fileUri)) 886 | const mappedPackageRelativeFilePath = path.posix.relative(depRootDir, fileURLToPath(mappedUri)) 887 | const mappedRepoRelativeFilePath = path.posix.join(subdir, mappedPackageRelativeFilePath) 888 | 889 | // Use the Sourcegraph endpoint from configuration 890 | const instanceUrl = new URL(configuration['typescript.sourcegraphUrl'] || 'https://sourcegraph.com') 891 | const accessToken = configuration['typescript.accessToken'] 892 | const repoName = await resolveRepository(cloneUrl, { instanceUrl, accessToken }) 893 | const commit = packageMeta.gitHead 894 | if (!commit) { 895 | logger.warn(`Package ${packageJson.name} has no gitHead metadata, using latest HEAD`) 896 | } 897 | const repoRev = [repoName, commit].filter(Boolean).join('@') 898 | const httpUrl = new URL(instanceUrl.href) 899 | httpUrl.pathname = path.posix.join(`/${repoRev}/-/raw/`, mappedRepoRelativeFilePath) 900 | if (accessToken) { 901 | httpUrl.username = accessToken 902 | } 903 | return { uri: httpUrl.href, range: mappedRange } 904 | } catch (err) { 905 | throwIfCancelled(token) 906 | logger.error(`Could not resolve location in dependency to an HTTP URL`, location, err) 907 | // Return the file URI as an opaque identifier 908 | return location 909 | } 910 | } 911 | 912 | // Not in node_modules, do not map to external repo, don't apply source maps. 913 | const httpUri = mapFileToHttpUrlSimple(fileUri) 914 | return { uri: httpUri.href, range: location.range } 915 | } 916 | 917 | /** 918 | * Maps Locations returned as a result from a definition, type definition, implementation or references call to HTTP URLs 919 | * and potentially to external repositories if the location is in node_modules. 920 | * 921 | * @param definition One or multiple locations on the file system. 922 | */ 923 | async function mapFileLocations( 924 | definition: Location | Location[] | null, 925 | { token }: { token: CancellationToken } 926 | ): Promise { 927 | if (!definition) { 928 | return [] 929 | } 930 | const arr = Array.isArray(definition) ? definition : [definition] 931 | return await Promise.all(arr.map(location => mapFileLocation(location, { token }))) 932 | } 933 | 934 | async function openTextDocument(fileUri: URL): Promise { 935 | if (openTextDocuments.has(fileUri)) { 936 | return 937 | } 938 | const didOpenParams: DidOpenTextDocumentParams = { 939 | textDocument: { 940 | uri: fileUri.href, 941 | version: 1, 942 | languageId: 'typescript', 943 | text: await pickResourceRetriever(fileUri).fetch(fileUri), 944 | }, 945 | } 946 | languageServer.connection.sendNotification(DidOpenTextDocumentNotification.type, didOpenParams) 947 | openTextDocuments.set(fileUri, didOpenParams) 948 | } 949 | 950 | /** 951 | * Forwards all requests of a certain method that returns Locations to the server, rewriting URIs. 952 | * It blocks on dependency installation if needed. 953 | * The returned locations get mapped to HTTP URLs and potentially to external repository URLs if they are in node_modules. 954 | */ 955 | function forwardLocationRequests

( 956 | type: RequestType 957 | ): void { 958 | dispatcher.setRequestHandler(type, async (params, token, span) => { 959 | const mappedParams = await mapTextDocumentPositionParams(params, { span, token }) 960 | const fileUri = new URL(mappedParams.textDocument.uri) 961 | // The TypeScript language server cannot service requests for documents that were not opened first 962 | await openTextDocument(fileUri) 963 | const result = await mapFileLocations( 964 | (await sendServerRequest(type, mappedParams, { tracer, span, token })) as Location | Location[] | null, 965 | { token } 966 | ) 967 | return result 968 | }) 969 | } 970 | 971 | forwardLocationRequests(DefinitionRequest.type) 972 | forwardLocationRequests(TypeDefinitionRequest.type) 973 | forwardLocationRequests(ReferencesRequest.type) 974 | forwardLocationRequests(ImplementationRequest.type) 975 | 976 | dispatcher.setRequestHandler(CodeActionRequest.type, async (params, token, span) => { 977 | const uri = new URL(params.textDocument.uri) 978 | const mappedParams: CodeActionParams = { 979 | ...params, 980 | textDocument: { 981 | uri: mapHttpToFileUrlSimple(uri).href, 982 | }, 983 | } 984 | const fileUri = new URL(mappedParams.textDocument.uri) 985 | // The TypeScript language server cannot service requests for documents that were not opened first 986 | await openTextDocument(fileUri) 987 | const result = await sendServerRequest(CodeActionRequest.type, mappedParams, { tracer, span, token }) 988 | return result 989 | }) 990 | 991 | connectionDisposables.add( 992 | dispatcher.observeNotification(DidOpenTextDocumentNotification.type).subscribe(params => { 993 | try { 994 | const uri = new URL(params.textDocument.uri) 995 | const fileUri = mapHttpToFileUrlSimple(uri) 996 | const mappedParams: DidOpenTextDocumentParams = { 997 | textDocument: { 998 | ...params.textDocument, 999 | uri: fileUri.href, 1000 | }, 1001 | } 1002 | languageServer.connection.sendNotification(DidOpenTextDocumentNotification.type, mappedParams) 1003 | openTextDocuments.set(fileUri, mappedParams) 1004 | } catch (err) { 1005 | logger.error('Error handling textDocument/didOpen notification', params, err) 1006 | } 1007 | }) 1008 | ) 1009 | }) 1010 | 1011 | httpServer.listen(port, () => { 1012 | globalLogger.log(`WebSocket server listening on port ${port}`) 1013 | }) 1014 | 1015 | const debugPort = Number(process.env.METRICS_PORT || 6060) 1016 | const debugServer = express() 1017 | const highlightCss = fs.readFileSync(require.resolve('highlight.js/styles/github.css'), 'utf-8') 1018 | /** Sends a plain text response, or highlighted HTML if `req.query.highlight` is set */ 1019 | function sendText(req: express.Request, res: express.Response, language: string, code: string) { 1020 | if (!req.query.highlight) { 1021 | res.setHeader('Content-Type', prometheus.register.contentType) 1022 | res.end(code) 1023 | } else { 1024 | res.setHeader('Content-Type', 'text/html') 1025 | const highlighted = highlight(language, code, true).value 1026 | res.end('

\n' + highlighted + '
\n' + '\n') 1027 | } 1028 | } 1029 | debugServer.get('/', (req, res) => { 1030 | res.send(` 1031 | 1035 | `) 1036 | }) 1037 | // Prometheus metrics 1038 | debugServer.get('/metrics', (req, res) => { 1039 | const metrics = prometheus.register.metrics() 1040 | sendText(req, res, 'php', metrics) 1041 | }) 1042 | // Endpoint to debug handle leaks (see also nodejs_active_handles_total Prometheus metric) 1043 | debugServer.get('/active_handles', (req, res) => { 1044 | const handles = { ...process._getActiveHandles() } // spread to get indexes as keys 1045 | const inspected = inspect(handles, req.query) 1046 | sendText(req, res, 'javascript', inspected) 1047 | }) 1048 | 1049 | debugServer.listen(debugPort, () => { 1050 | globalLogger.log(`Debug listening on http://localhost:${debugPort}`) 1051 | }) 1052 | -------------------------------------------------------------------------------- /src/tracing.ts: -------------------------------------------------------------------------------- 1 | import { FORMAT_TEXT_MAP, Span, SpanContext, SpanOptions, Tracer } from 'opentracing' 2 | import { ERROR, SPAN_KIND, SPAN_KIND_RPC_CLIENT } from 'opentracing/lib/ext/tags' 3 | import { CancellationToken, MessageConnection, NotificationType1, RequestType1 } from 'vscode-jsonrpc' 4 | 5 | export const canGenerateTraceUrl = (val: any): val is { generateTraceURL(): string } => 6 | typeof val === 'object' && val !== null && typeof val.generateTraceURL === 'function' 7 | 8 | /** 9 | * Traces a synchronous function by passing it a new child span. 10 | * The span is finished when the function returns. 11 | * If the function throws an Error, it is logged and the `error` tag set. 12 | * 13 | * @param operationName The operation name for the new span 14 | * @param childOf The parent span 15 | * @param operation The function to call 16 | */ 17 | export function traceSync( 18 | operationName: string, 19 | tracer: Tracer, 20 | childOf: Span | undefined, 21 | operation: (span: Span) => T 22 | ): T { 23 | const span = tracer.startSpan(operationName, { childOf }) 24 | try { 25 | return operation(span) 26 | } catch (err) { 27 | span.setTag(ERROR, true) 28 | logErrorEvent(span, err) 29 | throw err 30 | } finally { 31 | span.finish() 32 | } 33 | } 34 | 35 | /** 36 | * Traces a Promise-returning (or async) function by passing it a new child span. 37 | * The span is finished when the Promise is resolved. 38 | * If the Promise is rejected, the Error is logged and the `error` tag set. 39 | * 40 | * @param operationName The operation name for the new span 41 | * @param tracer OpenTracing tracer 42 | * @param childOfOrOptions The parent span or custom soptions 43 | * @param operation The function to call 44 | */ 45 | export async function tracePromise( 46 | operationName: string, 47 | tracer: Tracer, 48 | childOfOrOptions: Span | SpanContext | SpanOptions | undefined, 49 | operation: (span: Span) => Promise 50 | ): Promise { 51 | const span = tracer.startSpan( 52 | operationName, 53 | childOfOrOptions instanceof Span || childOfOrOptions instanceof SpanContext 54 | ? { childOf: childOfOrOptions } 55 | : childOfOrOptions 56 | ) 57 | try { 58 | return await operation(span) 59 | } catch (err) { 60 | span.setTag(ERROR, true) 61 | logErrorEvent(span, err) 62 | throw err 63 | } finally { 64 | span.finish() 65 | } 66 | } 67 | 68 | /** 69 | * Traces a Promise-returning (or async) function by passing it a new child span. 70 | * The span is finished when the Promise is resolved. 71 | * If the Promise is rejected, the Error is logged and the `error` tag set. 72 | * 73 | * @param operationName The operation name for the new span 74 | * @param tracer OpenTracing tracer 75 | * @param childOf The parent span 76 | * @param operation The function to call 77 | */ 78 | export async function* traceAsyncGenerator( 79 | operationName: string, 80 | tracer: Tracer, 81 | childOf: Span | undefined, 82 | asyncGenerator: (span: Span) => AsyncGenerator 83 | ): AsyncGenerator { 84 | const span = tracer.startSpan(operationName, { childOf }) 85 | try { 86 | yield* asyncGenerator(span) 87 | } catch (err) { 88 | span.setTag(ERROR, true) 89 | logErrorEvent(span, err) 90 | throw err 91 | } finally { 92 | span.finish() 93 | } 94 | } 95 | 96 | export function logErrorEvent(span: Span, err: Error): void { 97 | span.log({ event: ERROR, 'error.object': err, stack: err.stack, message: err.message }) 98 | } 99 | 100 | // Aliases because vscode-jsonrpc's interfaces are weird. 101 | export type RequestType = RequestType1 102 | export type NotificationType

= NotificationType1 103 | 104 | /** 105 | * Sends an LSP request traced with OpenTracing 106 | */ 107 | export async function sendTracedRequest( 108 | connection: Pick, 109 | type: RequestType, 110 | params: P, 111 | { span, tracer, token }: { span: Span; tracer: Tracer; token: CancellationToken } 112 | ): Promise { 113 | return await tracePromise( 114 | `Request ${type.method}`, 115 | tracer, 116 | { 117 | childOf: span, 118 | tags: { 119 | [SPAN_KIND]: SPAN_KIND_RPC_CLIENT, 120 | }, 121 | }, 122 | async span => { 123 | tracer.inject(span, FORMAT_TEXT_MAP, params) 124 | return await connection.sendRequest(type, params, token) 125 | } 126 | ) 127 | } 128 | -------------------------------------------------------------------------------- /src/tsconfig.ts: -------------------------------------------------------------------------------- 1 | import JSON5 from 'json5' 2 | import { writeFile } from 'mz/fs' 3 | import { Span, Tracer } from 'opentracing' 4 | import { fileURLToPath, URL } from 'url' 5 | import { CancellationToken } from 'vscode-jsonrpc' 6 | import { throwIfCancelled } from './cancellation' 7 | import { flatMapConcurrent } from './ix' 8 | import { Logger } from './logging' 9 | import { ResourceRetrieverPicker } from './resources' 10 | import { logErrorEvent, tracePromise } from './tracing' 11 | 12 | export async function sanitizeTsConfigs({ 13 | dir, 14 | pickResourceRetriever, 15 | tracer, 16 | span, 17 | token, 18 | logger, 19 | }: { 20 | dir: URL 21 | pickResourceRetriever: ResourceRetrieverPicker 22 | tracer: Tracer 23 | span?: Span 24 | logger: Logger 25 | token: CancellationToken 26 | }): Promise { 27 | throwIfCancelled(token) 28 | await tracePromise('Sanitize tsconfig.jsons', tracer, span, async span => { 29 | const pattern = new URL('**/tsconfig.json', dir) 30 | flatMapConcurrent(pickResourceRetriever(pattern).glob(pattern), 10, async function*( 31 | tsconfigUri 32 | ): AsyncGenerator { 33 | throwIfCancelled(token) 34 | let json: string | undefined 35 | try { 36 | json = await pickResourceRetriever(tsconfigUri).fetch(tsconfigUri) 37 | const tsconfig = JSON5.parse(json) 38 | if (tsconfig && tsconfig.compilerOptions && tsconfig.compilerOptions.plugins) { 39 | // Remove plugins for security reasons (they get loaded from node_modules) 40 | tsconfig.compilerOptions.plugins = undefined 41 | await writeFile(fileURLToPath(tsconfigUri), JSON.stringify(tsconfig)) 42 | } 43 | } catch (err) { 44 | throwIfCancelled(token) 45 | logger.error('Error sanitizing tsconfig.json at', tsconfigUri, json, err) 46 | logErrorEvent(span, err) 47 | } 48 | }) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /src/types/node/index.d.ts: -------------------------------------------------------------------------------- 1 | namespace NodeJS { 2 | interface Process { 3 | _getActiveHandles(): any 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/types/npm-registry-fetch/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'npm-registry-fetch' { 2 | declare namespace npmFetch { 3 | export interface NpmOptions { 4 | body?: Buffer | NodeJS.ReadableStream | object 5 | ca?: string | string[] 6 | cache?: string 7 | cert?: string 8 | 'fetch-retries'?: number 9 | 'fetch-retry-mintimeout'?: number 10 | 'fetch-retry-maxtimeout'?: number 11 | 'force-auth'?: object 12 | gzip?: boolean 13 | headers?: object 14 | 'ignore-body'?: boolean 15 | integrity?: string | object 16 | 'is-from-ci'?: boolean 17 | isFromCI?: boolean 18 | key?: string 19 | 'local-address'?: string 20 | registry?: string 21 | 'max-sockets'?: number 22 | method?: string 23 | noproxy?: boolean 24 | 'npm-session'?: string 25 | offline?: boolean 26 | retry?: object 27 | scope?: string 28 | 'strict-ssl'?: boolean 29 | timeout?: number 30 | 'prefer-offline'?: boolean 31 | 'prefer-online'?: boolean 32 | 'project-scope'?: string 33 | token?: string 34 | _authToken?: string 35 | 'user-agent'?: string 36 | username?: string 37 | password?: string 38 | spec?: string | object 39 | log?: object 40 | otp?: string | number 41 | proxy?: string 42 | query?: string | object 43 | refer?: string 44 | /** 45 | * Options scoped to a package scope or registry with a `:` before the option. 46 | */ 47 | [scopedOption: string]: any 48 | } 49 | export function json(url: string, options: NpmOptions): Promise 50 | } 51 | declare function npmFetch(url: string, options: NpmOptions): Promise 52 | export = npmFetch 53 | } 54 | -------------------------------------------------------------------------------- /src/uri.ts: -------------------------------------------------------------------------------- 1 | import { from } from 'ix/iterable' 2 | import { map } from 'ix/iterable/operators' 3 | import RelateUrl from 'relateurl' 4 | import { URL } from 'url' 5 | 6 | /** 7 | * Options to make sure that RelateUrl only outputs relative URLs and performs not other "smart" modifications. 8 | * They would mess up things like prefix checking. 9 | */ 10 | const RELATE_URL_OPTIONS: RelateUrl.Options = { 11 | // Make sure RelateUrl does not prefer root-relative URLs if shorter 12 | output: RelateUrl.PATH_RELATIVE, 13 | // Make sure RelateUrl does not remove trailing slash if present 14 | removeRootTrailingSlash: false, 15 | // Make sure RelateUrl does not remove default ports 16 | defaultPorts: {}, 17 | } 18 | 19 | /** 20 | * Like `path.relative()` but for URLs. 21 | * Inverse of `url.resolve()` or `new URL(relative, base)`. 22 | */ 23 | export const relativeUrl = (from: URL, to: URL): string => RelateUrl.relate(from.href, to.href, RELATE_URL_OPTIONS) 24 | 25 | /** 26 | * A Map of URIs to values. 27 | */ 28 | export class URLMap implements Map { 29 | private map: Map 30 | constructor(entries: Iterable<[URL, V]> = []) { 31 | this.map = new Map(from(entries).pipe(map(([uri, value]): [string, V] => [uri.href, value]))) 32 | } 33 | public get size(): number { 34 | return this.map.size 35 | } 36 | public get(key: URL): V | undefined { 37 | return this.map.get(key.href) 38 | } 39 | public has(key: URL): boolean { 40 | return this.map.has(key.href) 41 | } 42 | public set(key: URL, value: V): this { 43 | this.map.set(key.href, value) 44 | return this 45 | } 46 | public delete(key: URL): boolean { 47 | return this.map.delete(key.href) 48 | } 49 | public clear(): void { 50 | this.map.clear() 51 | } 52 | public forEach(callbackfn: (value: V, key: URL, map: Map) => void, thisArg?: any): void { 53 | // tslint:disable-next-line:ban 54 | this.map.forEach((value, key) => { 55 | callbackfn.call(thisArg, value, new URL(key), this) 56 | }) 57 | } 58 | public *entries(): IterableIterator<[URL, V]> { 59 | for (const [url, value] of this.map) { 60 | yield [new URL(url), value] 61 | } 62 | } 63 | public values(): IterableIterator { 64 | return this.map.values() 65 | } 66 | public *keys(): IterableIterator { 67 | for (const url of this.map.keys()) { 68 | yield new URL(url) 69 | } 70 | } 71 | public [Symbol.iterator]() { 72 | return this.entries() 73 | } 74 | public [Symbol.toStringTag] = 'URLMap' 75 | } 76 | 77 | /** 78 | * A Set of URIs. 79 | */ 80 | export class URLSet implements Set { 81 | private set: Set 82 | 83 | constructor(values: Iterable = []) { 84 | this.set = new Set(from(values).pipe(map(uri => uri.href))) 85 | } 86 | public get size(): number { 87 | return this.set.size 88 | } 89 | public has(key: URL): boolean { 90 | return this.set.has(key.href) 91 | } 92 | public add(key: URL): this { 93 | this.set.add(key.href) 94 | return this 95 | } 96 | public delete(key: URL): boolean { 97 | return this.set.delete(key.href) 98 | } 99 | public clear(): void { 100 | this.set.clear() 101 | } 102 | public forEach(callbackfn: (value: URL, key: URL, map: Set) => void, thisArg?: any): void { 103 | // tslint:disable-next-line:ban 104 | this.set.forEach(value => { 105 | const url = new URL(value) 106 | callbackfn.call(thisArg, url, url, this) 107 | }) 108 | } 109 | public *entries(): IterableIterator<[URL, URL]> { 110 | for (const url of this.values()) { 111 | yield [url, url] 112 | } 113 | } 114 | public *values(): IterableIterator { 115 | for (const value of this.set) { 116 | yield new URL(value) 117 | } 118 | } 119 | public keys(): IterableIterator { 120 | return this.values() 121 | } 122 | public [Symbol.iterator]() { 123 | return this.values() 124 | } 125 | public [Symbol.toStringTag] = 'URLSet' 126 | } 127 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logging' 2 | 3 | export function tryLogError(logger: Logger, func: () => void): void { 4 | try { 5 | func() 6 | } catch (err) { 7 | logger.error(err) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/yarn.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn } from 'child_process' 2 | // import { exec } from 'mz/child_process' 3 | import { Span, Tracer } from 'opentracing' 4 | import * as path from 'path' 5 | import { combineLatest, concat, fromEvent, merge, Unsubscribable } from 'rxjs' 6 | import { filter, map, switchMap, withLatestFrom } from 'rxjs/operators' 7 | import { Readable } from 'stream' 8 | import { CancellationToken, Disposable } from 'vscode-jsonrpc' 9 | import { createAbortError, throwIfCancelled } from './cancellation' 10 | import { disposeAll } from './disposable' 11 | import { Logger, NoopLogger } from './logging' 12 | import { Progress, ProgressProvider } from './progress' 13 | import { tracePromise } from './tracing' 14 | 15 | export interface YarnStep { 16 | message: string 17 | current: number 18 | total: number 19 | } 20 | export interface YarnProgressStart { 21 | id: number 22 | total: number 23 | } 24 | export interface YarnProgressTick { 25 | id: number 26 | current: number 27 | } 28 | export interface YarnProgressFinish { 29 | id: number 30 | } 31 | export interface YarnActivityStart { 32 | id: number 33 | } 34 | export interface YarnActivityTick { 35 | id: number 36 | name: string 37 | } 38 | export interface YarnActivityEnd { 39 | id: number 40 | } 41 | 42 | /** 43 | * Child process that emits additional events from yarn's JSON stream 44 | */ 45 | export interface YarnProcess extends ChildProcess { 46 | /** Emitted for verbose logs */ 47 | on(event: 'verbose', listener: (log: string) => void): this 48 | /** Emitted on a yarn step (e.g. Resolving, Fetching, Linking) */ 49 | on(event: 'step', listener: (step: YarnStep) => void): this 50 | 51 | on(event: 'activityStart', listener: (step: YarnActivityStart) => void): this 52 | on(event: 'activityTick', listener: (step: YarnActivityTick) => void): this 53 | on(event: 'activityEnd', listener: (step: YarnActivityEnd) => void): this 54 | 55 | on(event: 'progressStart', listener: (step: YarnProgressStart) => void): this 56 | on(event: 'progressTick', listener: (step: YarnProgressTick) => void): this 57 | on(event: 'progressFinish', listener: (step: YarnProgressFinish) => void): this 58 | 59 | /** Emitted if the process exited successfully */ 60 | on(event: 'success', listener: () => void): this 61 | /** Emitted on error event or non-zero exit code */ 62 | on(event: 'error', listener: (err: Error) => void): this 63 | on(event: 'exit', listener: (code: number, signal: string) => void): this 64 | on(event: string | symbol, listener: (...args: any[]) => void): this 65 | 66 | /** Emitted for verbose logs */ 67 | once(event: 'verbose', listener: (log: string) => void): this 68 | /** Emitted on a yarn step (e.g. Resolving, Fetching, Linking) */ 69 | once(event: 'step', listener: (step: YarnStep) => void): this 70 | 71 | once(event: 'activityStart', listener: (step: YarnActivityStart) => void): this 72 | once(event: 'activityTick', listener: (step: YarnActivityTick) => void): this 73 | once(event: 'activityEnd', listener: (step: YarnActivityEnd) => void): this 74 | 75 | once(event: 'progressStart', listener: (step: YarnProgressStart) => void): this 76 | once(event: 'progressTick', listener: (step: YarnProgressTick) => void): this 77 | once(event: 'progressFinish', listener: (step: YarnProgressFinish) => void): this 78 | 79 | /** Emitted if the process exited successfully */ 80 | once(event: 'success', listener: () => void): this 81 | /** Emitted on error event or non-zero exit code */ 82 | once(event: 'error', listener: (err: Error) => void): this 83 | once(event: 'exit', listener: (code: number, signal: string) => void): this 84 | once(event: string | symbol, listener: (...args: any[]) => void): this 85 | } 86 | 87 | export interface YarnSpawnOptions { 88 | /** The folder to run yarn in */ 89 | cwd: string 90 | 91 | /** The global directory to use (`--global-folder`) */ 92 | globalFolder: string 93 | 94 | /** The cache directory to use (`--cache-folder`) */ 95 | cacheFolder: string 96 | 97 | /** Whether to run yarn in verbose mode (`--verbose`) to get verbose events (e.g. "Copying file from A to B") */ 98 | verbose?: boolean 99 | 100 | /** Logger to use */ 101 | logger: Logger 102 | 103 | /** OpenTracing tracer */ 104 | tracer: Tracer 105 | 106 | /** OpenTracing parent span */ 107 | span?: Span 108 | } 109 | 110 | const YARN_BIN_JS = path.resolve(__dirname, '..', 'node_modules', 'yarn', 'lib', 'cli.js') 111 | 112 | /** 113 | * Spawns a yarn child process. 114 | * The returned child process emits additional events from the streamed JSON events yarn writes to STDIO. 115 | * An exit code of 0 causes a `success` event to be emitted, any other an `error` event 116 | * 117 | * @param options 118 | */ 119 | export function spawnYarn({ span = new Span(), logger = new NoopLogger(), ...options }: YarnSpawnOptions): YarnProcess { 120 | const args = [ 121 | YARN_BIN_JS, 122 | '--ignore-scripts', // Don't run package.json scripts 123 | '--ignore-platform', // Don't error on failing platform checks 124 | '--ignore-engines', // Don't check package.json engines field 125 | '--no-bin-links', // Don't create bin symlinks 126 | '--no-emoji', // Don't use emojis in output 127 | '--non-interactive', // Don't ask for any user input 128 | '--json', // Output a newline-delimited JSON stream 129 | '--link-duplicates', // Use hardlinks instead of copying 130 | '--pure-lockfile', // Trust the lockfile if exists 131 | 132 | // Use a separate global and cache folders per package.json 133 | // that we can clean up afterwards and don't interfere with concurrent installations 134 | '--global-folder', 135 | options.globalFolder, 136 | '--cache-folder', 137 | options.cacheFolder, 138 | ] 139 | if (options.verbose) { 140 | args.push('--verbose') 141 | } 142 | logger.log(`Starting yarn install`) 143 | const yarn: YarnProcess = spawn(process.execPath, args, { cwd: options.cwd }) 144 | 145 | /** Emitted error messages by yarn */ 146 | const errors: string[] = [] 147 | 148 | function parseStream(stream: Readable): void { 149 | let buffer = '' 150 | stream.on('data', chunk => { 151 | try { 152 | buffer += chunk 153 | const lines = buffer.split('\n') 154 | buffer = lines.pop()! 155 | for (const line of lines) { 156 | const event = JSON.parse(line) 157 | if (event.type === 'error') { 158 | // Only emit error event if non-zero exit code 159 | logger.error('yarn: ', event.data) 160 | errors.push(event.data) 161 | } else if (event.type !== 'success') { 162 | yarn.emit(event.type, event.data) 163 | } 164 | } 165 | } catch (err) { 166 | // E.g. JSON parse error 167 | yarn.emit('error', err) 168 | logger.error('yarn install', err, buffer) 169 | } 170 | }) 171 | } 172 | 173 | // Yarn writes JSON messages to both STDOUT and STDERR depending on event type 174 | parseStream(yarn.stdout!) 175 | parseStream(yarn.stderr!) 176 | 177 | yarn.on('exit', (code, signal) => { 178 | if (code === 0) { 179 | logger.log('yarn install done') 180 | yarn.emit('success') 181 | } else if (!signal) { 182 | const error = Object.assign(new Error(`yarn install failed: ${errors.join(', ')}`), { 183 | code, 184 | errors, 185 | }) 186 | logger.error(error) 187 | yarn.emit('error', error) 188 | } 189 | span.finish() 190 | }) 191 | 192 | // Trace steps, e.g. Resolving, Fetching, Linking 193 | yarn.on('step', step => { 194 | span.log({ event: 'step', message: step.message }) 195 | logger.log(`${step.current}/${step.total} ${step.message}`) 196 | }) 197 | 198 | return yarn 199 | } 200 | 201 | interface YarnInstallOptions extends YarnSpawnOptions { 202 | withProgress: ProgressProvider 203 | token: CancellationToken 204 | } 205 | 206 | /** 207 | * Wrapper around `spawnYarn()` returning a Promise and accepting an CancellationToken. 208 | */ 209 | export async function install({ 210 | logger, 211 | tracer, 212 | span, 213 | token, 214 | cwd, 215 | withProgress, 216 | ...spawnOptions 217 | }: YarnInstallOptions): Promise { 218 | throwIfCancelled(token) 219 | await tracePromise('yarn install', tracer, span, async span => { 220 | await withProgress('Dependency installation', async reporter => { 221 | const using: (Disposable | Unsubscribable)[] = [] 222 | try { 223 | // const [stdout] = await exec(`node ${YARN_BIN_JS} config list`, { cwd }) 224 | // logger.log('yarn config', stdout) 225 | await new Promise((resolve, reject) => { 226 | const yarn = spawnYarn({ ...spawnOptions, cwd, tracer, span, logger }) 227 | const steps = fromEvent(yarn, 'step') 228 | using.push( 229 | merge( 230 | combineLatest([ 231 | concat([''], fromEvent(yarn, 'activityTick').pipe(map(a => a.name))), 232 | steps, 233 | ]).pipe( 234 | map(([activityName, step]) => ({ 235 | message: [step.message, activityName].filter(Boolean).join(' - '), 236 | percentage: Math.round(((step.current - 1) / step.total) * 100), 237 | })) 238 | ), 239 | // Only listen to the latest progress 240 | fromEvent(yarn, 'progressStart').pipe( 241 | withLatestFrom(steps), 242 | switchMap(([{ total, id }, step]) => 243 | fromEvent(yarn, 'progressTick').pipe( 244 | filter(progress => progress.id === id), 245 | map(progress => ({ 246 | percentage: Math.round( 247 | ((step.current - 1 + progress.current / total) / step.total) * 100 248 | ), 249 | })) 250 | ) 251 | ) 252 | ) 253 | ).subscribe(reporter) 254 | ) 255 | 256 | yarn.on('success', resolve) 257 | yarn.on('error', reject) 258 | using.push( 259 | token.onCancellationRequested(() => { 260 | logger.log('Killing yarn process in ', cwd) 261 | yarn.kill() 262 | reject(createAbortError()) 263 | }) 264 | ) 265 | }) 266 | } finally { 267 | disposeAll(using) 268 | } 269 | }) 270 | }) 271 | } 272 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sourcegraph/tsconfig", 3 | "compilerOptions": { 4 | "target": "es2019", 5 | "esModuleInterop": true, 6 | "moduleResolution": "node", 7 | "skipLibCheck": true, 8 | "skipDefaultLibCheck": true, 9 | "declaration": true, 10 | "declarationMap": true, 11 | "sourceMap": true, 12 | "module": "commonjs", 13 | "lib": ["es2018", "esnext.asynciterable", "dom"], 14 | "typeRoots": ["./node_modules/@types", "./src/types"], 15 | "outDir": "dist", 16 | "rootDir": "src", 17 | }, 18 | "include": ["./src/**/*"], 19 | "files": [], 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "await-promise": [true, "Thenable"], 4 | "rxjs-no-wholesale": false, 5 | "typedef": false, 6 | "return-undefined": false, 7 | "no-console": true 8 | }, 9 | "extends": ["@sourcegraph/tslint-config"] 10 | } 11 | --------------------------------------------------------------------------------