diff --git a/.eslintignore b/.eslintignore index ae7798cc04e6c..fea65b495873f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -15,7 +15,6 @@ **/extensions/typescript-language-features/test-workspace/** **/extensions/typescript-language-features/extension.webpack.config.js **/extensions/typescript-language-features/extension-browser.webpack.config.js -**/extensions/typescript-language-features/web/** **/extensions/vscode-api-tests/testWorkspace/** **/extensions/vscode-api-tests/testWorkspace2/** **/fixtures/** diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 4a8da3e37f831..c50fc45c7ce85 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -38,6 +38,9 @@ "jsonc-parser": "^3.2.0", "semver": "5.5.1", "vscode-tas-client": "^0.1.63", + "@vscode/sync-api-client": "^0.7.2", + "@vscode/sync-api-common": "^0.7.2", + "@vscode/sync-api-service": "^0.7.3", "vscode-uri": "^3.0.3" }, "devDependencies": { @@ -1217,6 +1220,15 @@ "default": true, "description": "%configuration.suggest.objectLiteralMethodSnippets.enabled%", "scope": "resource" + }, + "typescript.experimental.tsserver.web.enableProjectWideIntellisense": { + "type": "boolean", + "default": false, + "description": "%typescript.experimental.tsserver.web.enableProjectWideIntellisense%", + "scope": "window", + "tags": [ + "experimental" + ] } } }, diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 0b3cf1da5984d..fdf08f0d41a9c 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -194,6 +194,8 @@ "configuration.suggest.classMemberSnippets.enabled": "Enable/disable snippet completions for class members.", "configuration.suggest.objectLiteralMethodSnippets.enabled": "Enable/disable snippet completions for methods in object literals. Requires using TypeScript 4.7+ in the workspace.", + "typescript.experimental.tsserver.web.enableProjectWideIntellisense": "Enable/disable project-wide IntelliSense on web. Requires that VS Code is running in a trusted context.", + "walkthroughs.nodejsWelcome.title": "Get started with JavaScript and Node.js", "walkthroughs.nodejsWelcome.description": "Make the most of Visual Studio Code's first-class JavaScript experience.", diff --git a/extensions/typescript-language-features/src/tsServer/fileWatchingManager.ts b/extensions/typescript-language-features/src/tsServer/fileWatchingManager.ts new file mode 100644 index 0000000000000..3386067163e93 --- /dev/null +++ b/extensions/typescript-language-features/src/tsServer/fileWatchingManager.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Utils } from 'vscode-uri'; +import { disposeAll, IDisposable } from '../utils/dispose'; +import { ResourceMap } from '../utils/resourceMap'; +import { Schemes } from '../utils/schemes'; + +type DirWatcherEntry = { + readonly uri: vscode.Uri; + readonly listeners: IDisposable[]; +}; + + +export class FileWatcherManager { + + private readonly _fileWatchers = new Map(); + + private readonly _dirWatchers = new ResourceMap<{ + readonly watcher: vscode.FileSystemWatcher; + refCount: number; + }>(uri => uri.toString(), { onCaseInsensitiveFileSystem: false }); + + create(id: number, uri: vscode.Uri, watchParentDirs: boolean, isRecursive: boolean, listeners: { create?: (uri: vscode.Uri) => void; change?: (uri: vscode.Uri) => void; delete?: (uri: vscode.Uri) => void }): void { + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(uri, isRecursive ? '**' : '*'), !listeners.create, !listeners.change, !listeners.delete); + const parentDirWatchers: DirWatcherEntry[] = []; + this._fileWatchers.set(id, { watcher, dirWatchers: parentDirWatchers }); + + if (listeners.create) { watcher.onDidCreate(listeners.create); } + if (listeners.change) { watcher.onDidChange(listeners.change); } + if (listeners.delete) { watcher.onDidDelete(listeners.delete); } + + if (watchParentDirs && uri.scheme !== Schemes.untitled) { + // We need to watch the parent directories too for when these are deleted / created + for (let dirUri = Utils.dirname(uri); dirUri.path.length > 1; dirUri = Utils.dirname(dirUri)) { + const dirWatcher: DirWatcherEntry = { uri: dirUri, listeners: [] }; + + let parentDirWatcher = this._dirWatchers.get(dirUri); + if (!parentDirWatcher) { + const glob = new vscode.RelativePattern(Utils.dirname(dirUri), Utils.basename(dirUri)); + const parentWatcher = vscode.workspace.createFileSystemWatcher(glob, !listeners.create, true, !listeners.delete); + parentDirWatcher = { refCount: 0, watcher: parentWatcher }; + this._dirWatchers.set(dirUri, parentDirWatcher); + } + parentDirWatcher.refCount++; + + if (listeners.create) { + dirWatcher.listeners.push(parentDirWatcher.watcher.onDidCreate(async () => { + // Just because the parent dir was created doesn't mean our file was created + try { + const stat = await vscode.workspace.fs.stat(uri); + if (stat.type === vscode.FileType.File) { + listeners.create!(uri); + } + } catch { + // Noop + } + })); + } + + if (listeners.delete) { + // When the parent dir is deleted, consider our file deleted too + // TODO: this fires if the file previously did not exist and then the parent is deleted + dirWatcher.listeners.push(parentDirWatcher.watcher.onDidDelete(listeners.delete)); + } + + parentDirWatchers.push(dirWatcher); + } + } + } + + delete(id: number): void { + const entry = this._fileWatchers.get(id); + if (entry) { + for (const dirWatcher of entry.dirWatchers) { + disposeAll(dirWatcher.listeners); + + const dirWatcherEntry = this._dirWatchers.get(dirWatcher.uri); + if (dirWatcherEntry) { + if (--dirWatcherEntry.refCount <= 0) { + dirWatcherEntry.watcher.dispose(); + this._dirWatchers.delete(dirWatcher.uri); + } + } + } + + entry.watcher.dispose(); + } + + this._fileWatchers.delete(id); + } +} diff --git a/extensions/typescript-language-features/src/tsServer/server.ts b/extensions/typescript-language-features/src/tsServer/server.ts index a035fbc9fdb6e..935d4730b2175 100644 --- a/extensions/typescript-language-features/src/tsServer/server.ts +++ b/extensions/typescript-language-features/src/tsServer/server.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { Cancellation } from '@vscode/sync-api-common/lib/common/messageCancellation'; import type * as Proto from '../protocol'; import { EventName } from '../protocol.const'; import { CallbackMap } from '../tsServer/callbackMap'; @@ -17,6 +18,7 @@ import Tracer from '../utils/tracer'; import { OngoingRequestCanceller } from './cancellation'; import { TypeScriptVersionManager } from './versionManager'; import { TypeScriptVersion } from './versionProvider'; +import { isWebAndHasSharedArrayBuffers } from '../utils/platform'; export enum ExecutionTarget { Semantic, @@ -64,6 +66,7 @@ export interface TsServerProcessFactory { kind: TsServerProcessKind, configuration: TypeScriptServiceConfiguration, versionManager: TypeScriptVersionManager, + extensionUri: vscode.Uri, ): TsServerProcess; } @@ -171,17 +174,16 @@ export class ProcessBasedTsServer extends Disposable implements ITypeScriptServe } } - private tryCancelRequest(seq: number, command: string): boolean { + private tryCancelRequest(request: Proto.Request, command: string): boolean { + const seq = request.seq; try { if (this._requestQueue.tryDeletePendingRequest(seq)) { this.logTrace(`Canceled request with sequence number ${seq}`); return true; } - if (this._requestCanceller.tryCancelOngoingRequest(seq)) { return true; } - this.logTrace(`Tried to cancel request with sequence number ${seq}. But request got already delivered.`); return false; } finally { @@ -221,8 +223,14 @@ export class ProcessBasedTsServer extends Disposable implements ITypeScriptServe this._callbacks.add(request.seq, { onSuccess: resolve as () => ServerResponse.Response | undefined, onError: reject, queuingStartTime: Date.now(), isAsync: executeInfo.isAsync }, executeInfo.isAsync); if (executeInfo.token) { + + const cancelViaSAB = isWebAndHasSharedArrayBuffers() + ? Cancellation.addData(request) + : undefined; + executeInfo.token.onCancellationRequested(() => { - this.tryCancelRequest(request.seq, command); + cancelViaSAB?.(); + this.tryCancelRequest(request, command); }); } }).catch((err: Error) => { diff --git a/extensions/typescript-language-features/src/tsServer/serverProcess.browser.ts b/extensions/typescript-language-features/src/tsServer/serverProcess.browser.ts index 302fe2bbb3032..ee47dee2628c1 100644 --- a/extensions/typescript-language-features/src/tsServer/serverProcess.browser.ts +++ b/extensions/typescript-language-features/src/tsServer/serverProcess.browser.ts @@ -2,30 +2,43 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - +/// import * as vscode from 'vscode'; import type * as Proto from '../protocol'; import { TypeScriptServiceConfiguration } from '../utils/configuration'; import { memoize } from '../utils/memoize'; import { TsServerProcess, TsServerProcessKind } from './server'; import { TypeScriptVersion } from './versionProvider'; - - - -declare const Worker: any; -declare type Worker = any; +import { ServiceConnection } from '@vscode/sync-api-common/browser'; +import { Requests, ApiService } from '@vscode/sync-api-service'; +import { TypeScriptVersionManager } from './versionManager'; +import { FileWatcherManager } from './fileWatchingManager'; +type BrowserWatchEvent = { + type: 'watchDirectory' | 'watchFile'; + recursive?: boolean; + uri: { + scheme: string; + authority: string; + path: string; + }; + id: number; +} | { + type: 'dispose'; + id: number; +}; export class WorkerServerProcess implements TsServerProcess { - public static fork( version: TypeScriptVersion, args: readonly string[], _kind: TsServerProcessKind, _configuration: TypeScriptServiceConfiguration, + _versionManager: TypeScriptVersionManager, + extensionUri: vscode.Uri, ) { const tsServerPath = version.tsServerPath; const worker = new Worker(tsServerPath); - return new WorkerServerProcess(worker, [ + return new WorkerServerProcess(worker, extensionUri, [ ...args, // Explicitly give TS Server its path so it can @@ -37,27 +50,78 @@ export class WorkerServerProcess implements TsServerProcess { private readonly _onDataHandlers = new Set<(data: Proto.Response) => void>(); private readonly _onErrorHandlers = new Set<(err: Error) => void>(); private readonly _onExitHandlers = new Set<(code: number | null, signal: string | null) => void>(); + private readonly watches = new FileWatcherManager(); + /** For communicating with TS server synchronously */ + private readonly tsserver: MessagePort; + /** For communicating watches asynchronously */ + private readonly watcher: MessagePort; + /** For communicating with filesystem synchronously */ + private readonly syncFs: MessagePort; public constructor( - private readonly worker: Worker, + /** For logging and initial setup */ + private readonly mainChannel: Worker, + extensionUri: vscode.Uri, args: readonly string[], ) { - worker.addEventListener('message', (msg: any) => { - if (msg.data.type === 'log') { - this.output.append(msg.data.body); + const tsserverChannel = new MessageChannel(); + const watcherChannel = new MessageChannel(); + const syncChannel = new MessageChannel(); + this.tsserver = tsserverChannel.port2; + this.watcher = watcherChannel.port2; + this.syncFs = syncChannel.port2; + this.tsserver.onmessage = (event) => { + if (event.data.type === 'log') { + console.error(`unexpected log message on tsserver channel: ${JSON.stringify(event)}`); return; } - for (const handler of this._onDataHandlers) { - handler(msg.data); + handler(event.data); } - }); - worker.onerror = (err: Error) => { + }; + this.watcher.onmessage = (event: MessageEvent) => { + switch (event.data.type) { + case 'dispose': { + this.watches.delete(event.data.id); + break; + } + case 'watchDirectory': + case 'watchFile': { + this.watches.create(event.data.id, vscode.Uri.from(event.data.uri), /*watchParentDirs*/ true, !!event.data.recursive, { + change: uri => this.watcher.postMessage({ type: 'watch', event: 'change', uri }), + create: uri => this.watcher.postMessage({ type: 'watch', event: 'create', uri }), + delete: uri => this.watcher.postMessage({ type: 'watch', event: 'delete', uri }), + }); + break; + } + default: + console.error(`unexpected message on watcher channel: ${JSON.stringify(event)}`); + } + }; + mainChannel.onmessage = (msg: any) => { + // for logging only + if (msg.data.type === 'log') { + this.output.append(msg.data.body); + return; + } + console.error(`unexpected message on main channel: ${JSON.stringify(msg)}`); + }; + mainChannel.onerror = (err: ErrorEvent) => { + console.error('error! ' + JSON.stringify(err)); for (const handler of this._onErrorHandlers) { - handler(err); + // TODO: The ErrorEvent type might be wrong; previously this was typed as Error and didn't have the property access. + handler(err.error); } }; - worker.postMessage(args); + this.output.append(`creating new MessageChannel and posting its port2 + args: ${args.join(' ')}\n`); + mainChannel.postMessage( + { args, extensionUri }, + [syncChannel.port1, tsserverChannel.port1, watcherChannel.port1] + ); + const connection = new ServiceConnection(syncChannel.port2); + new ApiService('vscode-wasm-typescript', connection); + connection.signalReady(); + this.output.append('done constructing WorkerServerProcess\n'); } @memoize @@ -66,7 +130,7 @@ export class WorkerServerProcess implements TsServerProcess { } write(serverRequest: Proto.Request): void { - this.worker.postMessage(serverRequest); + this.tsserver.postMessage(serverRequest); } onData(handler: (response: Proto.Response) => void): void { @@ -83,6 +147,10 @@ export class WorkerServerProcess implements TsServerProcess { } kill(): void { - this.worker.terminate(); + this.mainChannel.terminate(); + this.tsserver.close(); + this.watcher.close(); + this.syncFs.close(); } } + diff --git a/extensions/typescript-language-features/src/tsServer/spawner.ts b/extensions/typescript-language-features/src/tsServer/spawner.ts index abb909cc4b60b..891735c80c396 100644 --- a/extensions/typescript-language-features/src/tsServer/spawner.ts +++ b/extensions/typescript-language-features/src/tsServer/spawner.ts @@ -44,6 +44,7 @@ export class TypeScriptServerSpawner { private readonly _telemetryReporter: TelemetryReporter, private readonly _tracer: Tracer, private readonly _factory: TsServerProcessFactory, + private readonly _extensionUri: vscode.Uri, ) { } public spawn( @@ -152,7 +153,7 @@ export class TypeScriptServerSpawner { } this._logger.info(`<${kind}> Forking...`); - const process = this._factory.fork(version, args, kind, configuration, this._versionManager); + const process = this._factory.fork(version, args, kind, configuration, this._versionManager, this._extensionUri); this._logger.info(`<${kind}> Starting...`); return new ProcessBasedTsServer( diff --git a/extensions/typescript-language-features/src/typescriptServiceClient.ts b/extensions/typescript-language-features/src/typescriptServiceClient.ts index 891d7e4c21f1f..5f2611293c48d 100644 --- a/extensions/typescript-language-features/src/typescriptServiceClient.ts +++ b/extensions/typescript-language-features/src/typescriptServiceClient.ts @@ -19,16 +19,16 @@ import { TypeScriptVersionManager } from './tsServer/versionManager'; import { ITypeScriptVersionProvider, TypeScriptVersion } from './tsServer/versionProvider'; import { ClientCapabilities, ClientCapability, ExecConfig, ITypeScriptServiceClient, ServerResponse, TypeScriptRequests } from './typescriptService'; import API from './utils/api'; -import { areServiceConfigurationsEqual, ServiceConfigurationProvider, SyntaxServerConfiguration, TsServerLogLevel, TypeScriptServiceConfiguration } from './utils/configuration'; +import { ServiceConfigurationProvider, SyntaxServerConfiguration, TsServerLogLevel, TypeScriptServiceConfiguration, areServiceConfigurationsEqual } from './utils/configuration'; import { Disposable } from './utils/dispose'; import * as fileSchemes from './utils/fileSchemes'; import { Logger } from './utils/logger'; -import { isWeb } from './utils/platform'; +import { isWeb, isWebAndHasSharedArrayBuffers } from './utils/platform'; import { TypeScriptPluginPathsProvider } from './utils/pluginPathsProvider'; import { PluginManager, TypeScriptServerPlugin } from './utils/plugins'; import { TelemetryProperties, TelemetryReporter, VSCodeTelemetryReporter } from './utils/telemetry'; import Tracer from './utils/tracer'; -import { inferredProjectCompilerOptions, ProjectType } from './utils/tsconfig'; +import { ProjectType, inferredProjectCompilerOptions } from './utils/tsconfig'; export interface TsDiagnostics { @@ -214,7 +214,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType return this.apiVersion.fullVersionString; }); - this.typescriptServerSpawner = new TypeScriptServerSpawner(this.versionProvider, this._versionManager, this.logDirectoryProvider, this.pluginPathsProvider, this.logger, this.telemetryReporter, this.tracer, this.processFactory); + this.typescriptServerSpawner = new TypeScriptServerSpawner(this.versionProvider, this._versionManager, this.logDirectoryProvider, this.pluginPathsProvider, this.logger, this.telemetryReporter, this.tracer, this.processFactory, context.extensionUri); this._register(this.pluginManager.onDidUpdateConfig(update => { this.configurePlugin(update.pluginId, update.config); @@ -233,9 +233,16 @@ export default class TypeScriptServiceClient extends Disposable implements IType } if (isWeb()) { - return new ClientCapabilities( - ClientCapability.Syntax, - ClientCapability.EnhancedSyntax); + if (this.isProjectWideIntellisenseOnWebEnabled()) { + return new ClientCapabilities( + ClientCapability.Syntax, + ClientCapability.EnhancedSyntax, + ClientCapability.Semantic); + } else { + return new ClientCapabilities( + ClientCapability.Syntax, + ClientCapability.EnhancedSyntax); + } } if (this.apiVersion.gte(API.v400)) { @@ -253,6 +260,10 @@ export default class TypeScriptServiceClient extends Disposable implements IType private readonly _onDidChangeCapabilities = this._register(new vscode.EventEmitter()); readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; + private isProjectWideIntellisenseOnWebEnabled(): boolean { + return isWebAndHasSharedArrayBuffers() && this._configuration.enableProjectWideIntellisenseOnWeb; + } + private cancelInflightRequestsForResource(resource: vscode.Uri): void { if (this.serverState.type !== ServerState.Type.Running) { return; @@ -678,7 +689,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType } default: { - return this.inMemoryResourcePrefix + return (this.isProjectWideIntellisenseOnWebEnabled() ? '' : this.inMemoryResourcePrefix) + '/' + resource.scheme + '/' + (resource.authority || this.emptyAuthority) + (resource.path.startsWith('/') ? resource.path : '/' + resource.path) @@ -722,9 +733,16 @@ export default class TypeScriptServiceClient extends Disposable implements IType public toResource(filepath: string): vscode.Uri { if (isWeb()) { // On web, the stdlib paths that TS return look like: '/lib.es2015.collection.d.ts' + // TODO: Find out what extensionUri is when testing (should be http://localhost:8080/static/sources/extensions/typescript-language-features/) + // TODO: make sure that this code path is getting hit if (filepath.startsWith('/lib.') && filepath.endsWith('.d.ts')) { return vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'browser', 'typescript', filepath.slice(1)); } + const parts = filepath.match(/^\/([^\/]+)\/([^\/]*)\/(.+)$/); + if (parts) { + const resource = vscode.Uri.parse(parts[1] + '://' + (parts[2] === this.emptyAuthority ? '' : parts[2]) + '/' + parts[3]); + return this.bufferSyncSupport.toVsCodeResource(resource); + } } if (filepath.startsWith(this.inMemoryResourcePrefix)) { diff --git a/extensions/typescript-language-features/src/utils/configuration.ts b/extensions/typescript-language-features/src/utils/configuration.ts index d1bfe62f738e3..2436aae836888 100644 --- a/extensions/typescript-language-features/src/utils/configuration.ts +++ b/extensions/typescript-language-features/src/utils/configuration.ts @@ -110,6 +110,7 @@ export interface TypeScriptServiceConfiguration { readonly implicitProjectConfiguration: ImplicitProjectConfiguration; readonly disableAutomaticTypeAcquisition: boolean; readonly useSyntaxServer: SyntaxServerConfiguration; + readonly enableProjectWideIntellisenseOnWeb: boolean; readonly enableProjectDiagnostics: boolean; readonly maxTsServerMemory: number; readonly enablePromptUseWorkspaceTsdk: boolean; @@ -140,6 +141,7 @@ export abstract class BaseServiceConfigurationProvider implements ServiceConfigu implicitProjectConfiguration: new ImplicitProjectConfiguration(configuration), disableAutomaticTypeAcquisition: this.readDisableAutomaticTypeAcquisition(configuration), useSyntaxServer: this.readUseSyntaxServer(configuration), + enableProjectWideIntellisenseOnWeb: this.readEnableProjectWideIntellisenseOnWeb(configuration), enableProjectDiagnostics: this.readEnableProjectDiagnostics(configuration), maxTsServerMemory: this.readMaxTsServerMemory(configuration), enablePromptUseWorkspaceTsdk: this.readEnablePromptUseWorkspaceTsdk(configuration), @@ -222,4 +224,8 @@ export abstract class BaseServiceConfigurationProvider implements ServiceConfigu protected readEnableTsServerTracing(configuration: vscode.WorkspaceConfiguration): boolean { return configuration.get('typescript.tsserver.enableTracing', false); } + + private readEnableProjectWideIntellisenseOnWeb(configuration: vscode.WorkspaceConfiguration): boolean { + return configuration.get('typescript.experimental.tsserver.web.enableProjectWideIntellisense', false); + } } diff --git a/extensions/typescript-language-features/src/utils/dispose.ts b/extensions/typescript-language-features/src/utils/dispose.ts index 175acf7b36797..7b6627204d5ed 100644 --- a/extensions/typescript-language-features/src/utils/dispose.ts +++ b/extensions/typescript-language-features/src/utils/dispose.ts @@ -12,6 +12,10 @@ export function disposeAll(disposables: vscode.Disposable[]) { } } +export interface IDisposable { + dispose(): void; +} + export abstract class Disposable { private _isDisposed = false; diff --git a/extensions/typescript-language-features/src/utils/fileSchemes.ts b/extensions/typescript-language-features/src/utils/fileSchemes.ts index 64b4982cfa3a0..da45a3610127f 100644 --- a/extensions/typescript-language-features/src/utils/fileSchemes.ts +++ b/extensions/typescript-language-features/src/utils/fileSchemes.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isWeb } from './platform'; +import * as vscode from 'vscode'; + export const file = 'file'; export const untitled = 'untitled'; export const git = 'git'; @@ -14,12 +17,13 @@ export const memFs = 'memfs'; export const vscodeVfs = 'vscode-vfs'; export const officeScript = 'office-script'; -export const semanticSupportedSchemes = [ - file, - untitled, - walkThroughSnippet, - vscodeNotebookCell, -]; +export const semanticSupportedSchemes = isWeb() && vscode.workspace.workspaceFolders ? + vscode.workspace.workspaceFolders.map(folder => folder.uri.scheme) : [ + file, + untitled, + walkThroughSnippet, + vscodeNotebookCell, + ]; /** * File scheme for which JS/TS language feature should be disabled diff --git a/extensions/typescript-language-features/src/utils/platform.ts b/extensions/typescript-language-features/src/utils/platform.ts index 2d754bf405471..a7bdd8f30ff72 100644 --- a/extensions/typescript-language-features/src/utils/platform.ts +++ b/extensions/typescript-language-features/src/utils/platform.ts @@ -6,6 +6,9 @@ import * as vscode from 'vscode'; export function isWeb(): boolean { - // @ts-expect-error - return typeof navigator !== 'undefined' && vscode.env.uiKind === vscode.UIKind.Web; + return 'navigator' in globalThis && vscode.env.uiKind === vscode.UIKind.Web; +} + +export function isWebAndHasSharedArrayBuffers(): boolean { + return isWeb() && (globalThis as any)['crossOriginIsolated']; } diff --git a/extensions/typescript-language-features/src/utils/schemes.ts b/extensions/typescript-language-features/src/utils/schemes.ts new file mode 100644 index 0000000000000..3eae0754ad299 --- /dev/null +++ b/extensions/typescript-language-features/src/utils/schemes.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const Schemes = Object.freeze({ + file: 'file', + untitled: 'untitled', + mailto: 'mailto', + vscode: 'vscode', + 'vscode-insiders': 'vscode-insiders', + notebookCell: 'vscode-notebook-cell', +}); + +export function isOfScheme(scheme: string, link: string): boolean { + return link.toLowerCase().startsWith(scheme + ':'); +} diff --git a/extensions/typescript-language-features/web/.eslintrc.js b/extensions/typescript-language-features/web/.eslintrc.js new file mode 100644 index 0000000000000..e910c2f25102e --- /dev/null +++ b/extensions/typescript-language-features/web/.eslintrc.js @@ -0,0 +1,10 @@ +module.exports = { + "parserOptions": { + "tsconfigRootDir": __dirname, + "project": "./tsconfig.json" + }, + "rules": { + "@typescript-eslint/prefer-optional-chain": "warn", + "@typescript-eslint/prefer-readonly": "warn" + } +}; diff --git a/extensions/typescript-language-features/web/README.md b/extensions/typescript-language-features/web/README.md new file mode 100644 index 0000000000000..9cae35b8cf39f --- /dev/null +++ b/extensions/typescript-language-features/web/README.md @@ -0,0 +1,154 @@ +# vscode-wasm-typescript +Language server host for typescript using vscode's sync-api in the browser + +## TODOs + +### Prototype + +- [x] get semantic diagnostics rendering squigglies + - typescriptserviceclient.ts has some functions that look at `scheme` to determine some features (hasCapabilityForResource) (also getWorkspaceRootForResource) + - known schemes are in utils/fileSchemes.ts, but don't include vscode-test-web + - adding vscode-test-web in a couple places didn't help, maybe I need to be hackier + - nope, another predicate is `isWeb`, so I had to change place(s) it's used too +- [x] cancellation + +### Cleanup + +- [x] point webpack hack to node_modules; link those files to locally built ones +- [x] create one or more MessageChannels for various communication +- [x] shut down normal listener + - starting the server currently crashes because ts.sys isn't defined -- I think it's a race condition. + In any case it'll need to get shut down before then, which may not be possible without changing Typescript. + - LATER: Turns out you can skip the existing server by depending on tsserverlibrary instead of tsserver. +- [x] figure out a webpack-native way to generate tsserver.web.js if possible +- [x] path rewriting is pretty loosey-goosey; likely to be incorrect some of the time + - invert the logic from TypeScriptServiceClient.normalizedPath for requests + - invert the function from webServer.ts for responses (maybe) + - something with getWorkspaceRootForResource (or anything else that checks `resouce.scheme`) +- [x] put files one level down from virtual root +- [x] fill in missing environment files like lib.dom.d.ts + - toResource's isWeb branch *probably* knows where to find this, just need to put it in the virtual FS + - I guess during setup in serverProcess.browser.ts. + - Not sure whether it needs to have the data or just a fs entry. + - Wait, I don't know how files get added to the FS normally. +- [x] cancellation should only retain one cancellation checker + - the one that matches the current request id + - but that means tracking (or retrieving from tsserver) the request id (aka seq?) + - and correctly setting/resetting it on the cancellation token too. + - I looked at the tsserver code. I think the web case is close to the single-pipe node case, + so I just require that requestId is set in order to call the *current* cancellation checker. + - Any incoming message with a cancellation checker will overwrite the current one. +- [x] Cancellation code in vscode is suspiciously prototypey. + - Specifically, it adds the vscode-wasm cancellation to original cancellation code, but should actually switch to the former for web only. + - looks like `isWeb()` is a way to check for being on the web +- [x] create multiple watchers + - on-demand instead of watching everything and checking on watch firing +- [x] get file watching to work + - it could *already* work, I just don't know how to test it + - look at extensions/markdown-language-features/src/client/fileWatchingManager.ts to see if I can use that + - later: it is OK. its main difference is that you can watch files in not-yet-created directories, and it maintains + a web of directory watches that then check whether the file is eventually created. + - even later: well, it works even though it is similar to my code. + I'm not sure what is different. +- [x] copy fileWatchingManager.ts to web/ ; there's no sharing code between extensions +- [x] Find out scheme the web actually uses instead of vscode-test-web (or switch over entirely to isWeb) +- [x] Need to parse and pass args through so that the syntax server isn't hard-coded to actually be another semantic server +- [x] think about implementing all the other ServerHost methods + - [x] copy importPlugin from previous version of webServer.ts + - [x] also copy details from + - previous implementation (although it's syntax-only so only covers part) + - node implementation in typescript proper +- [x] make realpath support symlinks similarly to node's realpath. + - Johannes says that the filesystem automatically follows symlinks, + so I don't think this is needed. +- [x] organise webServer.ts into multiple files + - OR at least re-arrange it so the diff with the previous version is smaller + - split it into multiple files after the initial PR +- [x] clear out TODOs +- [x] add semicolons everywhere; vscode's lint doesn't seem to complain, but the code clearly uses them +- [x] Further questions about host methods based on existing implementations + - `require` -- is this needed? In TS, it's only used in project system + - `trace` -- is this needed? In TS, it's only used in project system + - `useCaseSensitiveFileNames` -- old version says 'false' is the + safest option, but the virtual fs is case sensitive. Is the old + version still better? + - `writeOutputIsTTY` -- I'm using apiClient.vscode.terminal.write -- is it a tty? + - `getWidthOfTerminal` -- I don't know where to find this on apiClient.vscode.terminal either + - `clearScreen` -- node version writes \x1BC to the terminal. Would + this work for vscode? + - `readFile/writeFile` -- TS handles utf8, utf16le and manually + converts big-endian to utf16 little-endian. How does the in-memory + filesystem handle this? There's no place to specify encoding. (And + `writeFile` currently ignores the flag to write a BOM.) + - `resolvePath` -- node version uses path.resolve. Is it OK to use + that? Or should I re-implement it? Just use identity like the old + web code? + - `getDirectories`/`readDirectory` + - the node code manually skips '.' and '..' in the array returned by + readDirectory. Is this needed? + - `createSHA256Hash` -- the browser version is async, so I skipped it + - `realpath` -- still skips symlinks, I need to figure out what node does + +### Bugs + +- [x] Response `seq` is always 0. +- [ ] current method of encoding /scheme/authority means that (node) module resolution looks for /scheme/node_modules and /node_modules + - even though they can't possibly exist + - probably not a problem though +- [x] problems pane doesn't clear problems issued on tsconfig. + - This is a known problem in normal usage as well. +- [x] renaming a file throws a No Project error to the console. +- [x] gotodef in another file throws and the editor has a special UI for it. + - definitionProviderBase.getSymbolLocations calls toOpenedFilePath which eventually calls the new / code + - then it calls client.execute which appears to actually request/response to the tsserver + - then the response body is mapped over location.file >> client.toResource >> fromTextSpan + - toResource has isWeb support, as well as (now unused) inMemoryResourcePrefix support + - so I can just redo whatever that did and it'll be fine + +### Done +- [x] need to update 0.2 -> 0.7.* API (once it's working properly) +- [x] including reshuffling the webpack hack if needed +- [x] need to use the settings recommended by Sheetal +- [x] ProjectService always requests a typesMap.json at the cwd +- [x] sync-api-client says fs is rooted at memfs:/sample-folder; the protocol 'memfs:' is confusing our file parsing I think +- [x] nothing ever seems to find tsconfig.json +- [x] messages aren't actually coming through, just the message from the first request + - fixed by simplifying the listener setup for now +- [x] once messages work, you can probably log by postMessage({ type: 'log', body: "some logging text" }) +- [x] implement realpath, modifiedtime, resolvepath, then turn semantic mode on +- [x] file watching implemented with saved map of filename to callback, and forwarding + +### Also + +- [ ] ATA will eventually need a host interface, or an improvement of the existing one (?) + +## Notes + +messages received by extension AND host use paths like ^/memfs/ts-nul-authority/sample-folder/file.ts +- problem: pretty sure the extension doesn't know what to do with that: it's not putting down error spans in file.ts +- question: why is the extension requesting quickinfo in that URI format? And it works! (probably because the result is a tooltip, not an in-file span) +- problem: weird concatenations with memfs:/ in the middle +- problem: weird concatenations with ^/memfs/ts-nul-authority in the middle + +question: where is the population of sample-folder with a bunch of files happening? + +question: Is that location writable while it's running? + +but readFile is getting called with things like memfs:/sample-folder/memfs:/typesMap.json + directoryExists with /sample-folder/node_modules/@types and /node_modules/@types + same for watchDirectory + watchDirectory with /sample-folder/^ and directoryExists with /sample-folder/^/memfs/ts-nul-authority/sample-folder/workspaces/ + watchFile with /sample-folder/memfs:/sample-folder/memfs:/lib.es2020.full.d.ts + +### LATER: + +OK, so the paths that tsserver has look like this: ^/scheme/mount/whatever.ts +but the paths the filesystem has look like this: scheme:/whatever.ts (not sure about 'mount', that's only when cloning from the fs) +so you have to shave off the scheme that the host combined with the path and put on the scheme that the vfs is using. + +### LATER 2: + +Some commands ask for getExecutingFilePath or getCurrentDirectory and cons up a path themselves. +This works, because URI.from({ scheme, path }) matches what the fs has in it +Problem: In *some* messages (all?), vscode then refers to /x.ts and ^/vscode-test-web/mount/x.ts (or ^/memfs/ts-nul-authority/x.ts) + diff --git a/extensions/typescript-language-features/web/webServer.ts b/extensions/typescript-language-features/web/webServer.ts index 680228881fa2d..17b9b27d8804f 100644 --- a/extensions/typescript-language-features/web/webServer.ts +++ b/extensions/typescript-language-features/web/webServer.ts @@ -4,262 +4,390 @@ *--------------------------------------------------------------------------------------------*/ /// /// + import * as ts from 'typescript/lib/tsserverlibrary'; +import { ApiClient, FileType, Requests } from '@vscode/sync-api-client'; +import { ClientConnection } from '@vscode/sync-api-common/browser'; +import { URI } from 'vscode-uri'; + +// GLOBALS +const watchFiles: Map = new Map(); +const watchDirectories: Map = new Map(); +let session: WorkerSession | undefined; +// END GLOBALS // BEGIN misc internals -const hasArgument: (argumentName: string) => boolean = (ts as any).server.hasArgument; -const findArgument: (argumentName: string) => string | undefined = (ts as any).server.findArgument; -const nowString: () => string = (ts as any).server.nowString; -const noop = () => { }; -const perfLogger = { - logEvent: noop, - logErrEvent(_: any) { }, - logPerfEvent(_: any) { }, - logInfoEvent(_: any) { }, - logStartCommand: noop, - logStopCommand: noop, - logStartUpdateProgram: noop, - logStopUpdateProgram: noop, - logStartUpdateGraph: noop, - logStopUpdateGraph: noop, - logStartResolveModule: noop, - logStopResolveModule: noop, - logStartParseSourceFile: noop, - logStopParseSourceFile: noop, - logStartReadFile: noop, - logStopReadFile: noop, - logStartBindFile: noop, - logStopBindFile: noop, - logStartScheduledOperation: noop, - logStopScheduledOperation: noop, -}; -const assertNever: (member: never) => never = (ts as any).Debug.assertNever; -const memoize: (callback: () => T) => () => T = (ts as any).memoize; -const ensureTrailingDirectorySeparator: (path: string) => string = (ts as any).ensureTrailingDirectorySeparator; -const getDirectoryPath: (path: string) => string = (ts as any).getDirectoryPath; -const directorySeparator: string = (ts as any).directorySeparator; -const combinePaths: (path: string, ...paths: (string | undefined)[]) => string = (ts as any).combinePaths; -const noopFileWatcher: ts.FileWatcher = { close: noop }; -const returnNoopFileWatcher = () => noopFileWatcher; -function getLogLevel(level: string | undefined) { - if (level) { - const l = level.toLowerCase(); - for (const name in ts.server.LogLevel) { - if (isNaN(+name) && l === name.toLowerCase()) { - return ts.server.LogLevel[name] as any as ts.server.LogLevel; - } - } - } - return undefined; -} - -const notImplemented: () => never = (ts as any).notImplemented; -const returnFalse: () => false = (ts as any).returnFalse; -const returnUndefined: () => undefined = (ts as any).returnUndefined; -const identity: (x: T) => T = (ts as any).identity; const indent: (str: string) => string = (ts as any).server.indent; const setSys: (s: ts.System) => void = (ts as any).setSys; -const validateLocaleAndSetLanguage: ( - locale: string, - sys: { getExecutingFilePath(): string; resolvePath(path: string): string; fileExists(fileName: string): boolean; readFile(fileName: string): string | undefined }, -) => void = (ts as any).validateLocaleAndSetLanguage; -const setStackTraceLimit: () => void = (ts as any).setStackTraceLimit; - +const combinePaths: (path: string, ...paths: (string | undefined)[]) => string = (ts as any).combinePaths; +const byteOrderMarkIndicator = '\uFEFF'; +const matchFiles: ( + path: string, + extensions: readonly string[] | undefined, + excludes: readonly string[] | undefined, + includes: readonly string[] | undefined, + useCaseSensitiveFileNames: boolean, + currentDirectory: string, + depth: number | undefined, + getFileSystemEntries: (path: string) => { files: readonly string[]; directories: readonly string[] }, + realpath: (path: string) => string +) => string[] = (ts as any).matchFiles; +const generateDjb2Hash = (ts as any).generateDjb2Hash; // End misc internals // BEGIN webServer/webServer.ts -interface HostWithWriteMessage { - writeMessage(s: any): void; -} -interface WebHost extends HostWithWriteMessage { - readFile(path: string): string | undefined; - fileExists(path: string): boolean; -} - -class BaseLogger implements ts.server.Logger { - private seq = 0; - private inGroup = false; - private firstInGroup = true; - constructor(protected readonly level: ts.server.LogLevel) { - } - static padStringRight(str: string, padding: string) { - return (str + padding).slice(0, padding.length); - } - close() { - } - getLogFileName(): string | undefined { - return undefined; - } - perftrc(s: string) { - this.msg(s, ts.server.Msg.Perf); - } - info(s: string) { - this.msg(s, ts.server.Msg.Info); - } - err(s: string) { - this.msg(s, ts.server.Msg.Err); - } - startGroup() { - this.inGroup = true; - this.firstInGroup = true; - } - endGroup() { - this.inGroup = false; - } - loggingEnabled() { - return true; +function fromResource(extensionUri: URI, uri: URI) { + if (uri.scheme === extensionUri.scheme + && uri.authority === extensionUri.authority + && uri.path.startsWith(extensionUri.path + '/dist/browser/typescript/lib.') + && uri.path.endsWith('.d.ts')) { + return uri.path; } - hasLevel(level: ts.server.LogLevel) { - return this.loggingEnabled() && this.level >= level; - } - msg(s: string, type: ts.server.Msg = ts.server.Msg.Err) { - switch (type) { - case ts.server.Msg.Info: - perfLogger.logInfoEvent(s); - break; - case ts.server.Msg.Perf: - perfLogger.logPerfEvent(s); - break; - default: // Msg.Err - perfLogger.logErrEvent(s); - break; - } - - if (!this.canWrite()) { return; } - - s = `[${nowString()}] ${s}\n`; - if (!this.inGroup || this.firstInGroup) { - const prefix = BaseLogger.padStringRight(type + ' ' + this.seq.toString(), ' '); - s = prefix + s; - } - this.write(s, type); - if (!this.inGroup) { - this.seq++; - } + return `/${uri.scheme}/${uri.authority}${uri.path}`; +} +function updateWatch(event: 'create' | 'change' | 'delete', uri: URI, extensionUri: URI) { + const kind = event === 'create' ? ts.FileWatcherEventKind.Created + : event === 'change' ? ts.FileWatcherEventKind.Changed + : event === 'delete' ? ts.FileWatcherEventKind.Deleted + : ts.FileWatcherEventKind.Changed; + const path = fromResource(extensionUri, uri); + if (watchFiles.has(path)) { + watchFiles.get(path)!.callback(path, kind); + return; } - protected canWrite() { - return true; + let found = false; + for (const watch of Array.from(watchDirectories.keys()).filter(dir => path.startsWith(dir))) { + watchDirectories.get(watch)!.callback(path); + found = true; } - protected write(_s: string, _type: ts.server.Msg) { + if (!found) { + console.error(`no watcher found for ${path}`); } } -type MessageLogLevel = 'info' | 'perf' | 'error'; -interface LoggingMessage { - readonly type: 'log'; - readonly level: MessageLogLevel; - readonly body: string; -} -class MainProcessLogger extends BaseLogger { - constructor(level: ts.server.LogLevel, private host: HostWithWriteMessage) { - super(level); - } - protected override write(body: string, type: ts.server.Msg) { - let level: MessageLogLevel; - switch (type) { - case ts.server.Msg.Info: - level = 'info'; - break; - case ts.server.Msg.Perf: - level = 'perf'; - break; - case ts.server.Msg.Err: - level = 'error'; - break; - default: - assertNever(type); - } - this.host.writeMessage({ - type: 'log', - level, - body, - } as LoggingMessage); - } -} +type ServerHostWithImport = ts.server.ServerHost & { importPlugin(root: string, moduleName: string): Promise }; + +function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient: ApiClient | undefined, args: string[], fsWatcher: MessagePort): ServerHostWithImport { + const currentDirectory = '/'; + const fs = apiClient?.vscode.workspace.fileSystem; + let watchId = 0; -function serverCreateWebSystem(host: WebHost, args: string[], getExecutingFilePath: () => string): - ts.server.ServerHost & { - importPlugin?(root: string, moduleName: string): Promise; - getEnvironmentVariable(name: string): string; - } { - const returnEmptyString = () => ''; - const getExecutingDirectoryPath = memoize(() => memoize(() => ensureTrailingDirectorySeparator(getDirectoryPath(getExecutingFilePath())))); + // Legacy web + const memoize: (callback: () => T) => () => T = (ts as any).memoize; + const ensureTrailingDirectorySeparator: (path: string) => string = (ts as any).ensureTrailingDirectorySeparator; + const getDirectoryPath: (path: string) => string = (ts as any).getDirectoryPath; + const directorySeparator: string = (ts as any).directorySeparator; + const executingFilePath = findArgument(args, '--executingFilePath') || location + ''; + const getExecutingDirectoryPath = memoize(() => memoize(() => ensureTrailingDirectorySeparator(getDirectoryPath(executingFilePath)))); // Later we could map ^memfs:/ to do something special if we want to enable more functionality like module resolution or something like that const getWebPath = (path: string) => path.startsWith(directorySeparator) ? path.replace(directorySeparator, getExecutingDirectoryPath()) : undefined; + return { - args, - newLine: '\r\n', // This can be configured by clients - useCaseSensitiveFileNames: false, // Use false as the default on web since that is the safest option - readFile: path => { - const webPath = getWebPath(path); - return webPath && host.readFile(webPath); + watchFile(path: string, callback: ts.FileWatcherCallback, pollingInterval?: number, options?: ts.WatchOptions): ts.FileWatcher { + watchFiles.set(path, { path, callback, pollingInterval, options }); + watchId++; + fsWatcher.postMessage({ type: 'watchFile', uri: toResource(path), id: watchId }); + return { + close() { + watchFiles.delete(path); + fsWatcher.postMessage({ type: 'dispose', id: watchId }); + } + }; }, - write: host.writeMessage.bind(host), - watchFile: returnNoopFileWatcher, - watchDirectory: returnNoopFileWatcher, - - getExecutingFilePath: () => directorySeparator, - getCurrentDirectory: returnEmptyString, // For inferred project root if projectRoot path is not set, normalizing the paths - - /* eslint-disable no-restricted-globals */ - setTimeout: (cb, ms, ...args) => setTimeout(cb, ms, ...args), - clearTimeout: handle => clearTimeout(handle), - setImmediate: x => setTimeout(x, 0), - clearImmediate: handle => clearTimeout(handle), - /* eslint-enable no-restricted-globals */ - - importPlugin: async (initialDir: string, moduleName: string): Promise => { - const packageRoot = combinePaths(initialDir, moduleName); + watchDirectory(path: string, callback: ts.DirectoryWatcherCallback, recursive?: boolean, options?: ts.WatchOptions): ts.FileWatcher { + watchDirectories.set(path, { path, callback, recursive, options }); + watchId++; + fsWatcher.postMessage({ type: 'watchDirectory', recursive, uri: toResource(path), id: watchId }); + return { + close() { + watchDirectories.delete(path); + fsWatcher.postMessage({ type: 'dispose', id: watchId }); + } + }; + }, + setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): any { + return setTimeout(callback, ms, ...args); + }, + clearTimeout(timeoutId: any): void { + clearTimeout(timeoutId); + }, + setImmediate(callback: (...args: any[]) => void, ...args: any[]): any { + return this.setTimeout(callback, 0, ...args); + }, + clearImmediate(timeoutId: any): void { + this.clearTimeout(timeoutId); + }, + importPlugin: async (root, moduleName) => { + const packageRoot = combinePaths(root, moduleName); let packageJson: any | undefined; try { const packageJsonResponse = await fetch(combinePaths(packageRoot, 'package.json')); packageJson = await packageJsonResponse.json(); - } - catch (e) { - return { module: undefined, error: new Error('Could not load plugin. Could not load "package.json".') }; + } catch (e) { + return { module: undefined, error: new Error(`Could not load plugin. Could not load 'package.json'.`) }; } const browser = packageJson.browser; if (!browser) { - return { module: undefined, error: new Error('Could not load plugin. No "browser" field found in package.json.') }; + return { module: undefined, error: new Error(`Could not load plugin. No 'browser' field found in package.json.`) }; } const scriptPath = combinePaths(packageRoot, browser); try { const { default: module } = await import(/* webpackIgnore: true */scriptPath); return { module, error: undefined }; - } - catch (e) { + } catch (e) { return { module: undefined, error: e }; } }, - exit: notImplemented, + args, + newLine: '\n', + useCaseSensitiveFileNames: true, + write: s => { + apiClient?.vscode.terminal.write(s); + }, + writeOutputIsTTY() { + return true; + }, + readFile(path) { + if (!fs) { + const webPath = getWebPath(path); + if (webPath) { + const request = new XMLHttpRequest(); + request.open('GET', webPath, /* asynchronous */ false); + request.send(); + return request.status === 200 ? request.responseText : undefined; + } else { + return undefined; + } + } + + try { + // @vscode/sync-api-common/connection says that Uint8Array is only a view on the bytes, so slice is needed + return new TextDecoder().decode(new Uint8Array(fs.readFile(toResource(path))).slice()); + } catch (e) { + logger.info(`Error fs.readFile`); + logger.info(JSON.stringify(e)); + return undefined; + } + }, + getFileSize(path) { + if (!fs) { + throw new Error('not supported'); + } + + try { + return fs.stat(toResource(path)).size; + } catch (e) { + logger.info(`Error fs.getFileSize`); + logger.info(JSON.stringify(e)); + return 0; + } + }, + writeFile(path, data, writeByteOrderMark) { + if (!fs) { + throw new Error('not supported'); + } + if (writeByteOrderMark) { + data = byteOrderMarkIndicator + data; + } + try { + fs.writeFile(toResource(path), new TextEncoder().encode(data)); + } catch (e) { + logger.info(`Error fs.writeFile`); + logger.info(JSON.stringify(e)); + } + }, + resolvePath(path: string): string { + return path; + }, + fileExists(path: string): boolean { + if (!fs) { + const webPath = getWebPath(path); + if (!webPath) { + return false; + } + + const request = new XMLHttpRequest(); + request.open('HEAD', webPath, /* asynchronous */ false); + request.send(); + return request.status === 200; + } - // Debugging related - getEnvironmentVariable: returnEmptyString, // TODO:: Used to enable debugging info - // tryEnableSourceMapsForHost?(): void; - // debugMode?: boolean; + try { + return fs.stat(toResource(path)).type === FileType.File; + } catch (e) { + logger.info(`Error fs.fileExists for ${path}`); + logger.info(JSON.stringify(e)); + return false; + } + }, + directoryExists(path: string): boolean { + if (!fs) { + return false; + } - // For semantic server mode - fileExists: path => { - const webPath = getWebPath(path); - return !!webPath && host.fileExists(webPath); + try { + return fs.stat(toResource(path)).type === FileType.Directory; + } catch (e) { + logger.info(`Error fs.directoryExists for ${path}`); + logger.info(JSON.stringify(e)); + return false; + } }, - directoryExists: returnFalse, // Module resolution - readDirectory: notImplemented, // Configured project, typing installer - getDirectories: () => [], // For automatic type reference directives - createDirectory: notImplemented, // compile On save - writeFile: notImplemented, // compile on save - resolvePath: identity, // Plugins - // realpath? // Module resolution, symlinks - // getModifiedTime // File watching - // createSHA256Hash // telemetry of the project - - // Logging related - // /*@internal*/ bufferFrom?(input: string, encoding?: string): Buffer; - // gc?(): void; - // getMemoryUsage?(): number; + createDirectory(path: string): void { + if (!fs) { + throw new Error('not supported'); + } + + try { + fs.createDirectory(toResource(path)); + } catch (e) { + logger.info(`Error fs.createDirectory`); + logger.info(JSON.stringify(e)); + } + }, + getExecutingFilePath(): string { + return currentDirectory; + }, + getCurrentDirectory(): string { + return currentDirectory; + }, + getDirectories(path: string): string[] { + return getAccessibleFileSystemEntries(path).directories.slice(); + }, + readDirectory(path: string, extensions?: readonly string[], excludes?: readonly string[], includes?: readonly string[], depth?: number): string[] { + return matchFiles(path, extensions, excludes, includes, /*useCaseSensitiveFileNames*/ true, currentDirectory, depth, getAccessibleFileSystemEntries, realpath); + }, + getModifiedTime(path: string): Date | undefined { + if (!fs) { + throw new Error('not supported'); + } + + try { + return new Date(fs.stat(toResource(path)).mtime); + } catch (e) { + logger.info(`Error fs.getModifiedTime`); + logger.info(JSON.stringify(e)); + return undefined; + } + }, + deleteFile(path: string): void { + if (!fs) { + throw new Error('not supported'); + } + + try { + fs.delete(toResource(path)); + } catch (e) { + logger.info(`Error fs.deleteFile`); + logger.info(JSON.stringify(e)); + } + }, + createHash: generateDjb2Hash, + /** This must be cryptographically secure. + The browser implementation, crypto.subtle.digest, is async so not possible to call from tsserver. */ + createSHA256Hash: undefined, + exit(): void { + removeEventListener('message', listener); + }, + realpath, + base64decode: input => Buffer.from(input, 'base64').toString('utf8'), + base64encode: input => Buffer.from(input).toString('base64'), }; + + /** For module resolution only; symlinks aren't supported yet. */ + function realpath(path: string): string { + // skip paths without .. or ./ or /. + if (!path.match(/\.\.|\/\.|\.\//)) { + return path; + } + const uri = toResource(path); + const out = [uri.scheme]; + if (uri.authority) { out.push(uri.authority); } + for (const part of uri.path.split('/')) { + switch (part) { + case '': + case '.': + break; + case '..': + //delete if there is something there to delete + out.pop(); + break; + default: + out.push(part); + } + } + return '/' + out.join('/'); + } + + function getAccessibleFileSystemEntries(path: string): { files: readonly string[]; directories: readonly string[] } { + if (!fs) { + throw new Error('not supported'); + } + + try { + const uri = toResource(path || '.'); + const entries = fs.readDirectory(uri); + const files: string[] = []; + const directories: string[] = []; + for (const [entry, type] of entries) { + // This is necessary because on some file system node fails to exclude + // '.' and '..'. See https://github.com/nodejs/node/issues/4002 + if (entry === '.' || entry === '..') { + continue; + } + + if (type === FileType.File) { + files.push(entry); + } + else if (type === FileType.Directory) { + directories.push(entry); + } + } + files.sort(); + directories.sort(); + return { files, directories }; + } catch (e) { + return { files: [], directories: [] }; + } + } + + /** + * Copied from toResource in typescriptServiceClient.ts + */ + function toResource(filepath: string) { + if (filepath.startsWith('/lib.') && filepath.endsWith('.d.ts')) { + return URI.from({ + scheme: extensionUri.scheme, + authority: extensionUri.authority, + path: extensionUri.path + '/dist/browser/typescript/' + filepath.slice(1) + }); + } + const parts = filepath.match(/^\/([^\/]+)\/([^\/]*)(?:\/(.+))?$/); + if (!parts) { + throw new Error('complex regex failed to match ' + filepath); + } + return URI.parse(parts[1] + '://' + (parts[2] === 'ts-nul-authority' ? '' : parts[2]) + (parts[3] ? '/' + parts[3] : '')); + } +} + +class WasmCancellationToken implements ts.server.ServerCancellationToken { + shouldCancel: (() => boolean) | undefined; + currentRequestId: number | undefined = undefined; + setRequest(requestId: number) { + this.currentRequestId = requestId; + } + resetRequest(requestId: number) { + if (requestId === this.currentRequestId) { + this.currentRequestId = undefined; + } else { + throw new Error(`Mismatched request id, expected ${this.currentRequestId} but got ${requestId}`); + } + } + isCancellationRequested(): boolean { + return this.currentRequestId !== undefined && !!this.shouldCancel && this.shouldCancel(); + } } interface StartSessionOptions { @@ -273,27 +401,47 @@ interface StartSessionOptions { syntaxOnly: ts.server.SessionOptions['syntaxOnly']; serverMode: ts.server.SessionOptions['serverMode']; } -class ServerWorkerSession extends ts.server.Session<{}> { +class WorkerSession extends ts.server.Session<{}> { + wasmCancellationToken: WasmCancellationToken; + listener: (message: any) => void; constructor( host: ts.server.ServerHost, - private webHost: HostWithWriteMessage, options: StartSessionOptions, + public port: MessagePort, logger: ts.server.Logger, - cancellationToken: ts.server.ServerCancellationToken, hrtime: ts.server.SessionOptions['hrtime'] ) { + const cancellationToken = new WasmCancellationToken(); super({ host, cancellationToken, ...options, - typingsInstaller: ts.server.nullTypingsInstaller, - byteLength: notImplemented, // Formats the message text in send of Session which is overriden in this class so not needed + typingsInstaller: ts.server.nullTypingsInstaller, // TODO: Someday! + byteLength: () => { throw new Error('Not implemented'); }, // Formats the message text in send of Session which is overriden in this class so not needed hrtime, logger, canUseEvents: true, }); - } + this.wasmCancellationToken = cancellationToken; + this.listener = (message: any) => { + // TEMP fix since Cancellation.retrieveCheck is not correct + function retrieveCheck2(data: any) { + if (!globalThis.crossOriginIsolated || !(data.$cancellationData instanceof SharedArrayBuffer)) { + return () => false; + } + const typedArray = new Int32Array(data.$cancellationData, 0, 1); + return () => { + return Atomics.load(typedArray, 0) === 1; + }; + } + const shouldCancel = retrieveCheck2(message.data); + if (shouldCancel) { + this.wasmCancellationToken.shouldCancel = shouldCancel; + } + this.onMessage(message.data); + }; + } public override send(msg: ts.server.protocol.Message) { if (msg.type === 'event' && !this.canUseEvents) { if (this.logger.hasLevel(ts.server.LogLevel.verbose)) { @@ -304,35 +452,34 @@ class ServerWorkerSession extends ts.server.Session<{}> { if (this.logger.hasLevel(ts.server.LogLevel.verbose)) { this.logger.info(`${msg.type}:${indent(JSON.stringify(msg))}`); } - this.webHost.writeMessage(msg); + this.port.postMessage(msg); } - protected override parseMessage(message: {}): ts.server.protocol.Request { return message as ts.server.protocol.Request; } - protected override toStringMessage(message: {}) { return JSON.stringify(message, undefined, 2); } + override exit() { + this.logger.info('Exiting...'); + this.port.removeEventListener('message', this.listener); + this.projectService.closeLog(); + close(); + } + listen() { + this.logger.info(`webServer.ts: tsserver starting to listen for messages on 'message'...`); + this.port.onmessage = this.listener; + } } // END webServer/webServer.ts // BEGIN tsserver/webServer.ts -const nullLogger: ts.server.Logger = { - close: noop, - hasLevel: returnFalse, - loggingEnabled: returnFalse, - perftrc: noop, - info: noop, - msg: noop, - startGroup: noop, - endGroup: noop, - getLogFileName: returnUndefined, -}; - -function parseServerMode(): ts.LanguageServiceMode | string | undefined { - const mode = findArgument('--serverMode'); +function parseServerMode(args: string[]): ts.LanguageServiceMode | string | undefined { + const mode = findArgument(args, '--serverMode'); if (!mode) { return undefined; } + switch (mode.toLowerCase()) { + case 'semantic': + return ts.LanguageServiceMode.Semantic; case 'partialsemantic': return ts.LanguageServiceMode.PartialSemantic; case 'syntactic': @@ -342,75 +489,6 @@ function parseServerMode(): ts.LanguageServiceMode | string | undefined { } } -function initializeWebSystem(args: string[]): StartInput { - createWebSystem(args); - const modeOrUnknown = parseServerMode(); - let serverMode: ts.LanguageServiceMode | undefined; - let unknownServerMode: string | undefined; - if (typeof modeOrUnknown === 'number') { serverMode = modeOrUnknown; } - else { unknownServerMode = modeOrUnknown; } - const logger = createLogger(); - - // enable deprecation logging - (ts as any).Debug.loggingHost = { - log(level: unknown, s: string) { - switch (level) { - case (ts as any).LogLevel.Error: - case (ts as any).LogLevel.Warning: - return logger.msg(s, ts.server.Msg.Err); - case (ts as any).LogLevel.Info: - case (ts as any).LogLevel.Verbose: - return logger.msg(s, ts.server.Msg.Info); - } - } - }; - - return { - args, - logger, - cancellationToken: ts.server.nullCancellationToken, - // Webserver defaults to partial semantic mode - serverMode: serverMode ?? ts.LanguageServiceMode.PartialSemantic, - unknownServerMode, - startSession: startWebSession - }; -} - -function createLogger() { - const cmdLineVerbosity = getLogLevel(findArgument('--logVerbosity')); - return cmdLineVerbosity !== undefined ? new MainProcessLogger(cmdLineVerbosity, { writeMessage }) : nullLogger; -} - -function writeMessage(s: any) { - postMessage(s); -} - -function createWebSystem(args: string[]) { - (ts as any).Debug.assert(ts.sys === undefined); - const webHost: WebHost = { - readFile: webPath => { - const request = new XMLHttpRequest(); - request.open('GET', webPath, /* asynchronous */ false); - request.send(); - return request.status === 200 ? request.responseText : undefined; - }, - fileExists: webPath => { - const request = new XMLHttpRequest(); - request.open('HEAD', webPath, /* asynchronous */ false); - request.send(); - return request.status === 200; - }, - writeMessage, - }; - // Do this after sys has been set as findArguments is going to work only then - const sys = serverCreateWebSystem(webHost, args, () => findArgument('--executingFilePath') || location + ''); - setSys(sys); - const localeStr = findArgument('--locale'); - if (localeStr) { - validateLocaleAndSetLanguage(localeStr, sys); - } -} - function hrtime(previous?: number[]) { const now = self.performance.now() * 1e-3; let seconds = Math.floor(now); @@ -427,102 +505,87 @@ function hrtime(previous?: number[]) { return [seconds, nanoseconds]; } -function startWebSession(options: StartSessionOptions, logger: ts.server.Logger, cancellationToken: ts.server.ServerCancellationToken) { - class WorkerSession extends ServerWorkerSession { - constructor() { - super( - ts.sys as ts.server.ServerHost & { tryEnableSourceMapsForHost?(): void; getEnvironmentVariable(name: string): string }, - { writeMessage }, - options, - logger, - cancellationToken, - hrtime); - } - - override exit() { - this.logger.info('Exiting...'); - this.projectService.closeLog(); - close(); - } - - listen() { - addEventListener('message', (message: any) => { - this.onMessage(message.data); - }); - } - } - - const session = new WorkerSession(); - - // Start listening - session.listen(); -} // END tsserver/webServer.ts // BEGIN tsserver/server.ts -function findArgumentStringArray(argName: string): readonly string[] { - const arg = findArgument(argName); - if (arg === undefined) { - return []; - } - return arg.split(',').filter(name => name !== ''); +function hasArgument(args: readonly string[], name: string): boolean { + return args.indexOf(name) >= 0; } - -interface StartInput { - args: readonly string[]; - logger: ts.server.Logger; - cancellationToken: ts.server.ServerCancellationToken; - serverMode: ts.LanguageServiceMode | undefined; - unknownServerMode?: string; - startSession: (option: StartSessionOptions, logger: ts.server.Logger, cancellationToken: ts.server.ServerCancellationToken) => void; +function findArgument(args: readonly string[], name: string): string | undefined { + const index = args.indexOf(name); + return 0 <= index && index < args.length - 1 + ? args[index + 1] + : undefined; +} +function findArgumentStringArray(args: readonly string[], name: string): readonly string[] { + const arg = findArgument(args, name); + return arg === undefined ? [] : arg.split(',').filter(name => name !== ''); } -function start({ args, logger, cancellationToken, serverMode, unknownServerMode, startSession: startServer }: StartInput, platform: string) { - const syntaxOnly = hasArgument('--syntaxOnly'); +async function initializeSession(args: string[], extensionUri: URI, platform: string, ports: { tsserver: MessagePort; sync: MessagePort; watcher: MessagePort }, logger: ts.server.Logger): Promise { + const modeOrUnknown = parseServerMode(args); + const serverMode = typeof modeOrUnknown === 'number' ? modeOrUnknown : undefined; + const unknownServerMode = typeof modeOrUnknown === 'string' ? modeOrUnknown : undefined; + const syntaxOnly = hasArgument(args, '--syntaxOnly'); logger.info(`Starting TS Server`); - logger.info(`Version: Moved from Typescript 5.0.0-dev`); + logger.info(`Version: 0.0.0`); logger.info(`Arguments: ${args.join(' ')}`); - logger.info(`Platform: ${platform} NodeVersion: N/A CaseSensitive: ${ts.sys.useCaseSensitiveFileNames}`); - logger.info(`ServerMode: ${serverMode} syntaxOnly: ${syntaxOnly} hasUnknownServerMode: ${unknownServerMode}`); + logger.info(`Platform: ${platform} CaseSensitive: true`); + logger.info(`ServerMode: ${serverMode} syntaxOnly: ${syntaxOnly} unknownServerMode: ${unknownServerMode}`); + const options = { + globalPlugins: findArgumentStringArray(args, '--globalPlugins'), + pluginProbeLocations: findArgumentStringArray(args, '--pluginProbeLocations'), + allowLocalPluginLoads: hasArgument(args, '--allowLocalPluginLoads'), + useSingleInferredProject: hasArgument(args, '--useSingleInferredProject'), + useInferredProjectPerProjectRoot: hasArgument(args, '--useInferredProjectPerProjectRoot'), + suppressDiagnosticEvents: hasArgument(args, '--suppressDiagnosticEvents'), + noGetErrOnBackgroundUpdate: hasArgument(args, '--noGetErrOnBackgroundUpdate'), + syntaxOnly, + serverMode + }; - setStackTraceLimit(); + let sys: ServerHostWithImport; + if (serverMode === ts.LanguageServiceMode.Semantic) { + const connection = new ClientConnection(ports.sync); + await connection.serviceReady(); - if ((ts as any).Debug.isDebugging) { - (ts as any).Debug.enableDebugInfo(); - } + sys = createServerHost(extensionUri, logger, new ApiClient(connection), args, ports.watcher); + } else { + sys = createServerHost(extensionUri, logger, undefined, args, ports.watcher); - if ((ts as any).sys.tryEnableSourceMapsForHost && /^development$/i.test((ts as any).sys.getEnvironmentVariable('NODE_ENV'))) { - (ts as any).sys.tryEnableSourceMapsForHost(); } - // Overwrites the current console messages to instead write to - // the log. This is so that language service plugins which use - // console.log don't break the message passing between tsserver - // and the client - console.log = (...args) => logger.msg(args.length === 1 ? args[0] : args.join(', '), ts.server.Msg.Info); - console.warn = (...args) => logger.msg(args.length === 1 ? args[0] : args.join(', '), ts.server.Msg.Err); - console.error = (...args) => logger.msg(args.length === 1 ? args[0] : args.join(', '), ts.server.Msg.Err); - - startServer( - { - globalPlugins: findArgumentStringArray('--globalPlugins'), - pluginProbeLocations: findArgumentStringArray('--pluginProbeLocations'), - allowLocalPluginLoads: hasArgument('--allowLocalPluginLoads'), - useSingleInferredProject: hasArgument('--useSingleInferredProject'), - useInferredProjectPerProjectRoot: hasArgument('--useInferredProjectPerProjectRoot'), - suppressDiagnosticEvents: hasArgument('--suppressDiagnosticEvents'), - noGetErrOnBackgroundUpdate: hasArgument('--noGetErrOnBackgroundUpdate'), - syntaxOnly, - serverMode - }, - logger, - cancellationToken - ); + setSys(sys); + session = new WorkerSession(sys, options, ports.tsserver, logger, hrtime); + session.listen(); } -// Get args from first message -const listener = (e: any) => { - removeEventListener('message', listener); - const args = e.data; - start(initializeWebSystem(args), 'web'); + + +let hasInitialized = false; +const listener = async (e: any) => { + if (!hasInitialized) { + hasInitialized = true; + if ('args' in e.data) { + const logger: ts.server.Logger = { + close: () => { }, + hasLevel: level => level <= ts.server.LogLevel.verbose, + loggingEnabled: () => true, + perftrc: () => { }, + info: s => postMessage({ type: 'log', body: s + '\n' }), + msg: s => postMessage({ type: 'log', body: s + '\n' }), + startGroup: () => { }, + endGroup: () => { }, + getLogFileName: () => 'tsserver.log', + }; + const [sync, tsserver, watcher] = e.ports as MessagePort[]; + const extensionUri = URI.from(e.data.extensionUri); + watcher.onmessage = (e: any) => updateWatch(e.data.event, URI.from(e.data.uri), extensionUri); + await initializeSession(e.data.args, extensionUri, 'vscode-web', { sync, tsserver, watcher }, logger); + } else { + console.error('unexpected message in place of initial message: ' + JSON.stringify(e.data)); + } + return; + } + console.error(`unexpected message on main channel: ${JSON.stringify(e)}`); }; addEventListener('message', listener); // END tsserver/server.ts diff --git a/extensions/typescript-language-features/yarn.lock b/extensions/typescript-language-features/yarn.lock index 82ee647644a99..8a9ac5632c025 100644 --- a/extensions/typescript-language-features/yarn.lock +++ b/extensions/typescript-language-features/yarn.lock @@ -206,6 +206,27 @@ "@microsoft/applicationinsights-web-basic" "^2.8.9" applicationinsights "2.3.6" +"@vscode/sync-api-client@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@vscode/sync-api-client/-/sync-api-client-0.7.2.tgz#0644bff66a5eff636bcd8eb483d34796b9f90d2d" + integrity sha512-HQHz57RVKmR8sTEen1Y/T3r6mzDX7IaUJz/O2RJkn0Qu9ThvCsakLP0N+1iiwPnPfUfmNSwQXbSw8bEQFPcpYQ== + dependencies: + "@vscode/sync-api-common" "0.7.2" + vscode-uri "3.0.3" + +"@vscode/sync-api-common@0.7.2", "@vscode/sync-api-common@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@vscode/sync-api-common/-/sync-api-common-0.7.2.tgz#705060ee6a0108c24e145e687613becdb4292b33" + integrity sha512-ne1XEeDIYA3mp4oo1QoF1fqFedd0Vf4ybMmLb9HixbTyXy/qwMNL2p6OjXjOsmx6w2q9eqzGA5W/OPRSJxTTIQ== + +"@vscode/sync-api-service@^0.7.3": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@vscode/sync-api-service/-/sync-api-service-0.7.3.tgz#6cb7bd23c4a7378e4b92ca3638501a9be1937152" + integrity sha512-m2AmmfG4uzfjLMgWRHQ3xnBkdwCiUTO68vdw1XuzMsOb39Jwm9xr5bVVxwOFR9lPC0FfO1H6FUxBhZQvg7itPA== + dependencies: + "@vscode/sync-api-common" "0.7.2" + vscode-uri "3.0.3" + applicationinsights@2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-2.3.6.tgz#91277ce44e5f6d2f85336922c05d90f8699c2e70" @@ -451,7 +472,7 @@ vscode-tas-client@^0.1.63: dependencies: tas-client "0.1.58" -vscode-uri@^3.0.3: +vscode-uri@3.0.3, vscode-uri@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.3.tgz#a95c1ce2e6f41b7549f86279d19f47951e4f4d84" integrity sha512-EcswR2S8bpR7fD0YPeS7r2xXExrScVMxg4MedACaWHEtx9ftCF/qHG1xGkolzTPcEmjTavCQgbVzHUIdTMzFGA==