Use comlink for extension host instead of JSON RPC 2 (#2344)

This commit is contained in:
Felix Becker 2019-02-24 11:51:21 +01:00 committed by GitHub
parent 330285b2c8
commit ebc8a9a245
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 1142 additions and 3706 deletions

View File

@ -3,8 +3,10 @@
import '../../config/polyfill'
import { without } from 'lodash'
import { noop } from 'rxjs'
import { fromEventPattern, noop, Observable } from 'rxjs'
import { bufferCount, filter, groupBy, map, mergeMap } from 'rxjs/operators'
import DPT from 'webext-domain-permission-toggle'
import { createExtensionHostWorker } from '../../../../../shared/src/api/extension/worker'
import * as browserAction from '../../browser/browserAction'
import * as omnibox from '../../browser/omnibox'
import * as permissions from '../../browser/permissions'
@ -14,7 +16,7 @@ import * as tabs from '../../browser/tabs'
import { featureFlagDefaults, FeatureFlags } from '../../browser/types'
import initializeCli from '../../libs/cli'
import { initSentry } from '../../libs/sentry'
import { createBlobURLForBundle, createExtensionHostWorker } from '../../platform/worker'
import { createBlobURLForBundle } from '../../platform/worker'
import { requestGraphQL } from '../../shared/backend/graphql'
import { resolveClientConfiguration } from '../../shared/backend/server'
import { DEFAULT_SOURCEGRAPH_URL, setSourcegraphUrl } from '../../shared/util/context'
@ -366,17 +368,74 @@ function setDefaultBrowserAction(): void {
browserAction.onClicked(noop)
setDefaultBrowserAction()
// This is the entrypoint for the extension host.
chrome.runtime.onConnect.addListener(port => {
const worker = createExtensionHostWorker()
worker.addEventListener('message', m => {
port.postMessage(m.data)
})
port.onMessage.addListener(m => {
worker.postMessage(m)
})
port.onDisconnect.addListener(() => worker.terminate())
})
// Add "Enable Sourcegraph on this domain" context menu item
DPT.addContextMenu()
const ENDPOINT_KIND_REGEX = /^(proxy|expose)-/
const portKind = (port: chrome.runtime.Port) => {
const match = port.name.match(ENDPOINT_KIND_REGEX)
return match && match[1]
}
/**
* A stream of EndpointPair created from Port objects emitted by chrome.runtime.onConnect.
*
* Each EndpointPair represents a connection with an instance of the content script.
*/
const endpointPairs: Observable<{ proxy: chrome.runtime.Port; expose: chrome.runtime.Port }> = fromEventPattern<
chrome.runtime.Port
>(
handler => chrome.runtime.onConnect.addListener(handler),
handler => chrome.runtime.onConnect.removeListener(handler)
).pipe(
groupBy(
port => (port.name || 'other').replace(ENDPOINT_KIND_REGEX, ''),
port => port,
group => group.pipe(bufferCount(2))
),
filter(group => group.key !== 'other'),
mergeMap(group =>
group.pipe(
bufferCount(2),
map(ports => {
const proxyPort = ports.find(port => portKind(port) === 'proxy')
if (!proxyPort) {
throw new Error('No proxy port')
}
const exposePort = ports.find(port => portKind(port) === 'expose')
if (!exposePort) {
throw new Error('No expose port')
}
return {
proxy: proxyPort,
expose: exposePort,
}
})
)
)
)
// Create one extension host worker per endpoint pair
endpointPairs.subscribe(({ proxy, expose }) => {
const { worker, clientEndpoints } = createExtensionHostWorker({ wrapEndpoints: true })
// Connect proxy client endpoint
clientEndpoints.proxy.start()
proxy.onMessage.addListener(message => {
clientEndpoints.proxy.postMessage(message)
})
clientEndpoints.proxy.addEventListener('message', ({ data }) => {
proxy.postMessage(data)
})
// Connect expose client endpoint
clientEndpoints.expose.start()
expose.onMessage.addListener(message => {
clientEndpoints.expose.postMessage(message)
})
clientEndpoints.expose.addEventListener('message', ({ data }) => {
expose.postMessage(data)
})
// Kill worker when either port disconnects
proxy.onDisconnect.addListener(() => worker.terminate())
expose.onDisconnect.addListener(() => worker.terminate())
})

View File

@ -5,7 +5,6 @@ import * as GQL from '../../../../shared/src/graphql/schema'
import { PlatformContext } from '../../../../shared/src/platform/context'
import { mutateSettings, updateSettings } from '../../../../shared/src/settings/edit'
import { EMPTY_SETTINGS_CASCADE, gqlToCascade } from '../../../../shared/src/settings/settings'
import { LocalStorageSubject } from '../../../../shared/src/util/LocalStorageSubject'
import { toPrettyBlobURL } from '../../../../shared/src/util/url'
import { ExtensionStorageSubject } from '../browser/ExtensionStorageSubject'
import * as runtime from '../browser/runtime'
@ -141,7 +140,6 @@ export function createPlatformContext({ urlToFile }: Pick<CodeHost, 'urlToFile'>
},
sourcegraphURL: sourcegraphUrl,
clientApplication: 'other',
traceExtensionHostCommunication: new LocalStorageSubject<boolean>('traceExtensionHostCommunication', false),
sideloadedExtensionURL: new ExtensionStorageSubject('sideloadedExtensionURL', null),
}
return context

View File

@ -1,103 +1,57 @@
import { Observable, of } from 'rxjs'
import * as MessageChannelAdapter from '@sourcegraph/comlink/messagechanneladapter'
import { Observable } from 'rxjs'
import uuid from 'uuid'
import { MessageTransports } from '../../../../shared/src/api/protocol/jsonrpc2/connection'
import { Message } from '../../../../shared/src/api/protocol/jsonrpc2/messages'
import {
AbstractMessageReader,
AbstractMessageWriter,
DataCallback,
MessageReader,
MessageWriter,
} from '../../../../shared/src/api/protocol/jsonrpc2/transport'
import { createWebWorkerMessageTransports } from '../../../../shared/src/api/protocol/jsonrpc2/transports/webWorker'
import { createExtensionHost as createInPageExtensionHost } from '../../../../shared/src/api/extension/worker'
import { EndpointPair } from '../../../../shared/src/platform/context'
import { isInPage } from '../context'
import { createExtensionHostWorker } from './worker'
/**
* Spawns an extension and returns a communication channel to it.
* Returns an observable of a communication channel to an extension host.
*/
export function createExtensionHost(): Observable<MessageTransports> {
export function createExtensionHost(): Observable<EndpointPair> {
if (isInPage) {
return createInPageExtensionHost()
return createInPageExtensionHost({ wrapEndpoints: false })
}
const channelID = uuid.v4()
return of(createPortMessageTransports(chrome.runtime.connect({ name: channelID })))
}
function createInPageExtensionHost(): Observable<MessageTransports> {
const worker = createExtensionHostWorker()
const messageTransports = createWebWorkerMessageTransports(worker)
return new Observable(sub => {
sub.next(messageTransports)
return () => worker.terminate()
const id = uuid.v4()
return new Observable(subscriber => {
const proxyPort = chrome.runtime.connect({ name: `proxy-${id}` })
const exposePort = chrome.runtime.connect({ name: `expose-${id}` })
subscriber.next({
proxy: endpointFromPort(proxyPort),
expose: endpointFromPort(exposePort),
})
return () => {
proxyPort.disconnect()
exposePort.disconnect()
}
})
}
class PortMessageReader extends AbstractMessageReader implements MessageReader {
private pending: Message[] = []
private callback: DataCallback | null = null
constructor(private port: chrome.runtime.Port) {
super()
port.onMessage.addListener((message: any) => {
try {
if (this.callback) {
this.callback(message)
} else {
this.pending.push(message)
}
} catch (err) {
this.fireError(err)
function endpointFromPort(port: chrome.runtime.Port): MessagePort {
const listeners = new Map<(event: MessageEvent) => any, (message: object, port: chrome.runtime.Port) => void>()
return MessageChannelAdapter.wrap({
send(data): void {
port.postMessage(data)
},
addEventListener(event, messageListener): void {
if (event !== 'message') {
return
}
})
port.onDisconnect.addListener(() => {
this.fireClose()
})
}
public listen(callback: DataCallback): void {
if (this.callback) {
throw new Error('callback is already set')
}
this.callback = callback
while (this.pending.length !== 0) {
callback(this.pending.pop()!)
}
}
public unsubscribe(): void {
super.unsubscribe()
this.callback = null
this.port.disconnect()
}
}
class PortMessageWriter extends AbstractMessageWriter implements MessageWriter {
private errorCount = 0
constructor(private port: chrome.runtime.Port) {
super()
}
public write(message: Message): void {
try {
this.port.postMessage(message)
} catch (error) {
this.fireError(error, message, ++this.errorCount)
}
}
public unsubscribe(): void {
super.unsubscribe()
this.port.disconnect()
}
}
/** Creates JSON-RPC2 message transports for the Web Worker message communication interface. */
function createPortMessageTransports(port: chrome.runtime.Port): MessageTransports {
return {
reader: new PortMessageReader(port),
writer: new PortMessageWriter(port),
}
const chromePortListener = (data: object) => {
messageListener.call(this, new MessageEvent('message', { data }))
}
listeners.set(messageListener, chromePortListener)
port.onMessage.addListener(chromePortListener)
},
removeEventListener(event, messageListener): void {
if (event !== 'message') {
return
}
const chromePortListener = listeners.get(messageListener)
if (!chromePortListener) {
return
}
port.onMessage.removeListener(chromePortListener)
},
})
}

View File

@ -1,9 +1,4 @@
import { ajax } from 'rxjs/ajax'
import ExtensionHostWorker from 'worker-loader?inline!../../../../shared/src/api/extension/main.worker.ts'
export function createExtensionHostWorker(): ExtensionHostWorker {
return new ExtensionHostWorker()
}
export async function createBlobURLForBundle(bundleURL: string): Promise<string> {
const req = await ajax({

View File

@ -15,7 +15,7 @@ const config = {
// https://github.com/facebook/create-react-app/issues/5241#issuecomment-426269242 for more information on why
// this is necessary.
transformIgnorePatterns: [
'/node_modules/(?!abortable-rx|@sourcegraph/react-loading-spinner|@sourcegraph/codeintellify)',
'/node_modules/(?!abortable-rx|@sourcegraph/react-loading-spinner|@sourcegraph/codeintellify|@sourcegraph/comlink)',
],
moduleNameMapper: { '\\.s?css$': 'identity-obj-proxy' },

View File

@ -139,6 +139,7 @@
"jest": "^23.6.0",
"json-schema-ref-parser": "^6.0.2",
"json-schema-to-typescript": "^6.1.0",
"message-port-polyfill": "^0.1.0",
"mini-css-extract-plugin": "^0.5.0",
"mkdirp-promise": "^5.0.1",
"mz": "^2.7.0",
@ -181,6 +182,7 @@
"@sentry/browser": "^4.5.4",
"@slimsag/react-shortcuts": "^1.2.1",
"@sourcegraph/codeintellify": "^6.0.3",
"@sourcegraph/comlink": "^3.1.1-fork.3",
"@sourcegraph/extension-api-types": "link:packages/@sourcegraph/extension-api-types",
"@sourcegraph/react-loading-spinner": "0.0.7",
"@sqs/jsonc-parser": "^1.0.3",

View File

@ -1211,10 +1211,14 @@ declare module 'sourcegraph' {
/** @deprecated Use an observer instead of a complete callback */
subscribe(next: null | undefined, error: null | undefined, complete: () => void): Unsubscribable
/** @deprecated Use an observer instead of an error callback */
subscribe(next: null | undefined, error: (error: any) => void, complete?: () => void): Unsubscribable
subscribe(next: null | undefined, error: (error: any) => void, complete?: (() => void) | null): Unsubscribable
/** @deprecated Use an observer instead of a complete callback */
subscribe(next: (value: T) => void, error: null | undefined, complete: () => void): Unsubscribable
subscribe(next?: (value: T) => void, error?: (error: any) => void, complete?: () => void): Unsubscribable
subscribe(
next?: ((value: T) => void) | null,
error?: ((error: any) => void) | null,
complete?: (() => void) | null
): Unsubscribable
}
/**

View File

@ -0,0 +1,24 @@
import { ClientCodeEditorAPI } from './codeEditor'
import { ClientCommandsAPI } from './commands'
import { ClientConfigurationAPI } from './configuration'
import { ClientContextAPI } from './context'
import { ClientLanguageFeaturesAPI } from './languageFeatures'
import { ClientSearchAPI } from './search'
import { ClientViewsAPI } from './views'
import { ClientWindowsAPI } from './windows'
/**
* The API that is exposed from the client (main thread) to the extension host (worker)
*/
export interface ClientAPI {
ping(): 'pong'
context: ClientContextAPI
configuration: ClientConfigurationAPI
search: ClientSearchAPI
languageFeatures: ClientLanguageFeaturesAPI
commands: ClientCommandsAPI
windows: ClientWindowsAPI
codeEditor: ClientCodeEditorAPI
views: ClientViewsAPI
}

View File

@ -1,14 +1,13 @@
import { ProxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { TextDocumentDecoration } from '@sourcegraph/extension-api-types'
import { flatten, values } from 'lodash'
import { BehaviorSubject, Observable, Subscription } from 'rxjs'
import { handleRequests } from '../../common/proxy'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { ProvideTextDocumentDecorationSignature } from '../services/decoration'
import { FeatureProviderRegistry } from '../services/registry'
import { TextDocumentIdentifier } from '../types/textDocument'
/** @internal */
export interface ClientCodeEditorAPI {
export interface ClientCodeEditorAPI extends ProxyValue {
$setDecorations(resource: string, decorationType: string, decorations: TextDocumentDecoration[]): void
}
@ -20,6 +19,8 @@ interface PreviousDecorations {
/** @internal */
export class ClientCodeEditor implements ClientCodeEditorAPI {
public readonly [proxyValueSymbol] = true
private subscriptions = new Subscription()
/** Map of document URI to its decorations (last published by the server). */
@ -27,10 +28,7 @@ export class ClientCodeEditor implements ClientCodeEditorAPI {
private previousDecorations: PreviousDecorations = {}
constructor(
connection: Connection,
private registry: FeatureProviderRegistry<undefined, ProvideTextDocumentDecorationSignature>
) {
constructor(private registry: FeatureProviderRegistry<undefined, ProvideTextDocumentDecorationSignature>) {
this.subscriptions.add(
this.registry.registerProvider(
undefined,
@ -38,8 +36,6 @@ export class ClientCodeEditor implements ClientCodeEditorAPI {
this.getDecorationsSubject(textDocument.uri)
)
)
handleRequests(connection, 'codeEditor', this)
}
public $setDecorations(resource: string, decorationType: string, decorations: TextDocumentDecoration[]): void {

View File

@ -1,48 +1,24 @@
import { Subscription } from 'rxjs'
import { createProxyAndHandleRequests } from '../../common/proxy'
import { ExtCommandsAPI } from '../../extension/api/commands'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { ProxyValue, proxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { Unsubscribable } from 'sourcegraph'
import { CommandRegistry } from '../services/command'
import { SubscriptionMap } from './common'
/** @internal */
export interface ClientCommandsAPI {
$unregister(id: number): void
$registerCommand(id: number, command: string): void
export interface ClientCommandsAPI extends ProxyValue {
$registerCommand(name: string, command: (...args: any) => any): Unsubscribable & ProxyValue
$executeCommand(command: string, args: any[]): Promise<any>
}
/** @internal */
export class ClientCommands implements ClientCommandsAPI {
private subscriptions = new Subscription()
private registrations = new SubscriptionMap()
private proxy: ExtCommandsAPI
export class ClientCommands implements ClientCommandsAPI, ProxyValue {
public readonly [proxyValueSymbol] = true
constructor(connection: Connection, private registry: CommandRegistry) {
this.subscriptions.add(this.registrations)
constructor(private registry: CommandRegistry) {}
this.proxy = createProxyAndHandleRequests('commands', connection, this)
}
public $unregister(id: number): void {
this.registrations.remove(id)
}
public $registerCommand(id: number, command: string): void {
this.registrations.add(
id,
this.registry.registerCommand({
command,
run: (...args: any[]): any => this.proxy.$executeCommand(id, args),
})
)
public $registerCommand(command: string, run: (...args: any) => any): Unsubscribable & ProxyValue {
return proxyValue(this.registry.registerCommand({ command, run }))
}
public $executeCommand(command: string, args: any[]): Promise<any> {
return this.registry.executeCommand({ command, arguments: args })
}
public unsubscribe(): void {
this.subscriptions.unsubscribe()
}
}

View File

@ -1,53 +1,51 @@
import { Unsubscribable } from 'sourcegraph'
import { ProxyResult, proxyValueSymbol } from '@sourcegraph/comlink'
import { noop } from 'lodash'
import { from, Observable, observable, Subscription } from 'rxjs'
import { mergeMap } from 'rxjs/operators'
import { Subscribable } from 'sourcegraph'
import { ProxySubscribable } from '../../extension/api/common'
import { syncSubscription } from '../../util'
/**
* Manages a map of subscriptions keyed on a numeric ID.
* When a Subscribable is returned from the other thread (wrapped with `proxySubscribable()`),
* this thread gets a `Promise` for a `Subscribable` _proxy_ where `subscribe()` returns a `Promise<Unsubscribable>`.
* This function wraps that proxy in a real Rx Observable where `subscribe()` returns an `Unsubscribable` directly as expected.
*
* @internal
* @param proxyPromise The proxy to the `ProxyObservable` in the other thread
*/
export class SubscriptionMap {
private map = new Map<number, Unsubscribable>()
/**
* Adds a new subscription with the given {@link id}.
*
* @param id - A unique identifier for this subscription among all other entries in this map.
* @param subscription - The subscription, unsubscribed when {@link SubscriptionMap#remove} is called.
* @throws If there already exists an entry with the given {@link id}.
*/
public add(id: number, subscription: Unsubscribable): void {
if (this.map.has(id)) {
throw new Error(`subscription already exists with ID ${id}`)
}
this.map.set(id, subscription)
}
/**
* Unsubscribes the subscription that was previously added with the given {@link id}, and removes it from the
* map.
*/
public remove(id: number): void {
const subscription = this.map.get(id)
if (!subscription) {
throw new Error(`no subscription with ID ${id}`)
}
try {
subscription.unsubscribe()
} finally {
this.map.delete(id)
}
}
/**
* Unsubscribes all subscriptions in this map and clears it.
*/
public unsubscribe(): void {
try {
for (const subscription of this.map.values()) {
subscription.unsubscribe()
}
} finally {
this.map.clear()
}
}
}
export const wrapRemoteObservable = <T>(proxyPromise: Promise<ProxyResult<ProxySubscribable<T>>>): Observable<T> =>
from(proxyPromise).pipe(
mergeMap(
proxySubscribable =>
// tslint:disable-next-line: no-object-literal-type-assertion
({
// Needed for Rx type check
[observable](): Subscribable<T> {
return this
},
subscribe(...args: any[]): Subscription {
// Always subscribe with an object because the other side
// is unable to tell if a Proxy is a function or an observer object
// (they always appear as functions)
let proxyObserver: Parameters<(typeof proxySubscribable)['subscribe']>[0]
if (typeof args[0] === 'function') {
proxyObserver = {
[proxyValueSymbol]: true,
next: args[0] || noop,
error: args[1] || noop,
complete: args[2] || noop,
}
} else {
const partialObserver = args[0] || {}
proxyObserver = {
[proxyValueSymbol]: true,
next: partialObserver.next ? val => partialObserver.next(val) : noop,
error: partialObserver.error ? err => partialObserver.error(err) : noop,
complete: partialObserver.complete ? () => partialObserver.complete() : noop,
}
}
return syncSubscription(proxySubscribable.subscribe(proxyObserver))
},
} as Subscribable<T>)
)
)

View File

@ -1,27 +1,25 @@
import { ProxyResult, ProxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { from, Subscription } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import { isSettingsValid } from '../../../settings/settings'
import { createProxyAndHandleRequests } from '../../common/proxy'
import { ExtConfigurationAPI } from '../../extension/api/configuration'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { SettingsEdit, SettingsService } from '../services/settings'
/** @internal */
export interface ClientConfigurationAPI {
$acceptConfigurationUpdate(edit: SettingsEdit): Promise<void>
export interface ClientConfigurationAPI extends ProxyValue {
$acceptConfigurationUpdate(edit: SettingsEdit): void
}
/**
* @internal
* @template C - The configuration schema.
*/
export class ClientConfiguration<C> implements ClientConfigurationAPI {
export class ClientConfiguration<C> implements ClientConfigurationAPI, ProxyValue {
public readonly [proxyValueSymbol] = true
private subscriptions = new Subscription()
private proxy: ExtConfigurationAPI<C>
constructor(connection: Connection, private settingsService: SettingsService<C>) {
this.proxy = createProxyAndHandleRequests('configuration', connection, this)
constructor(private proxy: ProxyResult<ExtConfigurationAPI<C>>, private settingsService: SettingsService<C>) {
this.subscriptions.add(
from(settingsService.data)
.pipe(

View File

@ -1,16 +1,14 @@
import { Subscription } from 'rxjs'
import { ContextValues } from 'sourcegraph'
import { handleRequests } from '../../common/proxy'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { ProxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { ContextValues, Unsubscribable } from 'sourcegraph'
/** @internal */
export interface ClientContextAPI {
export interface ClientContextAPI extends ProxyValue {
$acceptContextUpdates(updates: ContextValues): void
}
/** @internal */
export class ClientContext implements ClientContextAPI {
private subscriptions = new Subscription()
export class ClientContext implements ClientContextAPI, Unsubscribable {
public readonly [proxyValueSymbol] = true
/**
* Context keys set by this server. To ensure that context values are cleaned up, all context properties that
@ -20,9 +18,7 @@ export class ClientContext implements ClientContextAPI {
*/
private keys = new Set<string>()
constructor(connection: Connection, private updateContext: (updates: ContextValues) => void) {
handleRequests(connection, 'context', this)
}
constructor(private updateContext: (updates: ContextValues) => void) {}
public $acceptContextUpdates(updates: ContextValues): void {
for (const key of Object.keys(updates)) {
@ -41,7 +37,5 @@ export class ClientContext implements ClientContextAPI {
}
this.keys.clear()
this.updateContext(updates)
this.subscriptions.unsubscribe()
}
}

View File

@ -1,26 +1,22 @@
import { ProxyResult } from '@sourcegraph/comlink'
import { Observable, Subscription } from 'rxjs'
import { createProxyAndHandleRequests } from '../../common/proxy'
import { ExtDocumentsAPI } from '../../extension/api/documents'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { TextDocumentItem } from '../types/textDocument'
import { SubscriptionMap } from './common'
/** @internal */
export class ClientDocuments {
private subscriptions = new Subscription()
private registrations = new SubscriptionMap()
private proxy: ExtDocumentsAPI
constructor(connection: Connection, modelTextDocuments: Observable<TextDocumentItem[] | null>) {
this.proxy = createProxyAndHandleRequests('documents', connection, this)
constructor(
private proxy: ProxyResult<ExtDocumentsAPI>,
modelTextDocuments: Observable<TextDocumentItem[] | null>
) {
this.subscriptions.add(
modelTextDocuments.subscribe(docs => {
// tslint:disable-next-line: no-floating-promises
this.proxy.$acceptDocumentData(docs || [])
})
)
this.subscriptions.add(this.registrations)
}
public unsubscribe(): void {

View File

@ -1,15 +1,13 @@
import { ProxyResult } from '@sourcegraph/comlink'
import { isEqual } from 'lodash'
import { from, Subscription } from 'rxjs'
import { bufferCount, startWith } from 'rxjs/operators'
import { createProxyAndHandleRequests } from '../../common/proxy'
import { ExtExtensionsAPI } from '../../extension/api/extensions'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { ExecutableExtension, ExtensionsService } from '../services/extensionsService'
/** @internal */
export class ClientExtensions {
private subscriptions = new Subscription()
private proxy: ExtExtensionsAPI
/**
* Implements the client side of the extensions API.
@ -18,9 +16,7 @@ export class ClientExtensions {
* @param extensions An observable that emits the set of extensions that should be activated
* upon subscription and whenever it changes.
*/
constructor(connection: Connection, extensionRegistry: ExtensionsService) {
this.proxy = createProxyAndHandleRequests('extensions', connection, this)
constructor(private proxy: ProxyResult<ExtExtensionsAPI>, extensionRegistry: ExtensionsService) {
this.subscriptions.add(
from(extensionRegistry.activeExtensions)
.pipe(

View File

@ -1,11 +1,8 @@
import { ProxyResult, ProxyValue, proxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { Hover, Location } from '@sourcegraph/extension-api-types'
import { from, Observable, Subscription } from 'rxjs'
import { map } from 'rxjs/operators'
import { DocumentSelector } from 'sourcegraph'
import { createProxyAndHandleRequests } from '../../common/proxy'
import { ExtLanguageFeaturesAPI } from '../../extension/api/languageFeatures'
import { DocumentSelector, Unsubscribable } from 'sourcegraph'
import { ProxySubscribable } from '../../extension/api/common'
import { ReferenceParams, TextDocumentPositionParams, TextDocumentRegistrationOptions } from '../../protocol'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { ProvideTextDocumentHoverSignature } from '../services/hover'
import {
ProvideTextDocumentLocationSignature,
@ -13,32 +10,57 @@ import {
TextDocumentReferencesProviderRegistry,
} from '../services/location'
import { FeatureProviderRegistry } from '../services/registry'
import { SubscriptionMap } from './common'
import { wrapRemoteObservable } from './common'
/** @internal */
export interface ClientLanguageFeaturesAPI {
$unregister(id: number): void
$registerHoverProvider(id: number, selector: DocumentSelector): void
$registerDefinitionProvider(id: number, selector: DocumentSelector): void
$registerTypeDefinitionProvider(id: number, selector: DocumentSelector): void
$registerImplementationProvider(id: number, selector: DocumentSelector): void
$registerReferenceProvider(id: number, selector: DocumentSelector): void
export interface ClientLanguageFeaturesAPI extends ProxyValue {
$registerHoverProvider(
selector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Hover | null | undefined>) & ProxyValue
>
): Unsubscribable & ProxyValue
$registerDefinitionProvider(
selector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Location[]>) & ProxyValue
>
): Unsubscribable & ProxyValue
$registerTypeDefinitionProvider(
selector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Location[]>) & ProxyValue
>
): Unsubscribable & ProxyValue
$registerImplementationProvider(
selector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Location[]>) & ProxyValue
>
): Unsubscribable & ProxyValue
$registerReferenceProvider(
selector: DocumentSelector,
providerFunction: ProxyResult<((params: ReferenceParams) => ProxySubscribable<Location[]>) & ProxyValue>
): Unsubscribable & ProxyValue
/**
* @param idStr The `id` argument in the extension's {@link sourcegraph.languages.registerLocationProvider}
* call.
*/
$registerLocationProvider(id: number, idStr: string, selector: DocumentSelector): void
$registerLocationProvider(
idStr: string,
selector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Location[]>) & ProxyValue
>
): Unsubscribable & ProxyValue
}
/** @internal */
export class ClientLanguageFeatures implements ClientLanguageFeaturesAPI {
private subscriptions = new Subscription()
private registrations = new SubscriptionMap()
private proxy: ExtLanguageFeaturesAPI
export class ClientLanguageFeatures implements ClientLanguageFeaturesAPI, ProxyValue {
public readonly [proxyValueSymbol] = true
constructor(
connection: Connection,
private hoverRegistry: FeatureProviderRegistry<
TextDocumentRegistrationOptions,
ProvideTextDocumentHoverSignature
@ -57,93 +79,84 @@ export class ClientLanguageFeatures implements ClientLanguageFeaturesAPI {
>,
private referencesRegistry: TextDocumentReferencesProviderRegistry,
private locationRegistry: TextDocumentLocationProviderIDRegistry
) {
this.subscriptions.add(this.registrations)
) {}
this.proxy = createProxyAndHandleRequests('languageFeatures', connection, this)
}
public $unregister(id: number): void {
this.registrations.remove(id)
}
public $registerHoverProvider(id: number, selector: DocumentSelector): void {
this.registrations.add(
id,
this.hoverRegistry.registerProvider(
{ documentSelector: selector },
(params: TextDocumentPositionParams): Observable<Hover | null | undefined> =>
from(this.proxy.$observeHover(id, params.textDocument.uri, params.position))
public $registerHoverProvider(
documentSelector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Hover | null | undefined>) & ProxyValue
>
): Unsubscribable & ProxyValue {
return proxyValue(
this.hoverRegistry.registerProvider({ documentSelector }, params =>
wrapRemoteObservable(providerFunction(params))
)
)
}
public $registerDefinitionProvider(id: number, selector: DocumentSelector): void {
this.registrations.add(
id,
this.definitionRegistry.registerProvider(
{ documentSelector: selector },
(params: TextDocumentPositionParams): Observable<Location | Location[]> =>
from(this.proxy.$observeDefinition(id, params.textDocument.uri, params.position)).pipe(
map(result => result || [])
)
public $registerDefinitionProvider(
documentSelector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Location[]>) & ProxyValue
>
): Unsubscribable & ProxyValue {
return proxyValue(
this.definitionRegistry.registerProvider({ documentSelector }, params =>
wrapRemoteObservable(providerFunction(params))
)
)
}
public $registerTypeDefinitionProvider(id: number, selector: DocumentSelector): void {
this.registrations.add(
id,
this.typeDefinitionRegistry.registerProvider(
{ documentSelector: selector },
(params: TextDocumentPositionParams): Observable<Location | Location[]> =>
from(this.proxy.$observeTypeDefinition(id, params.textDocument.uri, params.position)).pipe(
map(result => result || [])
)
public $registerTypeDefinitionProvider(
documentSelector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Location[]>) & ProxyValue
>
): Unsubscribable & ProxyValue {
return proxyValue(
this.typeDefinitionRegistry.registerProvider({ documentSelector }, params =>
wrapRemoteObservable(providerFunction(params))
)
)
}
public $registerImplementationProvider(id: number, selector: DocumentSelector): void {
this.registrations.add(
id,
this.implementationRegistry.registerProvider(
{ documentSelector: selector },
(params: TextDocumentPositionParams): Observable<Location | Location[]> =>
from(this.proxy.$observeImplementation(id, params.textDocument.uri, params.position)).pipe(
map(result => result || [])
)
public $registerImplementationProvider(
documentSelector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Location[]>) & ProxyValue
>
): Unsubscribable & ProxyValue {
return proxyValue(
this.implementationRegistry.registerProvider({ documentSelector }, params =>
wrapRemoteObservable(providerFunction(params))
)
)
}
public $registerReferenceProvider(id: number, selector: DocumentSelector): void {
this.registrations.add(
id,
this.referencesRegistry.registerProvider(
{ documentSelector: selector },
(params: ReferenceParams): Observable<Location[]> =>
from(
this.proxy.$observeReferences(id, params.textDocument.uri, params.position, params.context)
).pipe(map(result => result || []))
public $registerReferenceProvider(
documentSelector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Location[]>) & ProxyValue
>
): Unsubscribable & ProxyValue {
return proxyValue(
this.referencesRegistry.registerProvider({ documentSelector }, params =>
wrapRemoteObservable(providerFunction(params))
)
)
}
public $registerLocationProvider(id: number, idStr: string, selector: DocumentSelector): void {
this.registrations.add(
id,
this.locationRegistry.registerProvider(
{ id: idStr, documentSelector: selector },
(params: TextDocumentPositionParams): Observable<Location[]> =>
from(this.proxy.$observeLocations(id, params.textDocument.uri, params.position)).pipe(
map(result => result || [])
)
public $registerLocationProvider(
id: string,
documentSelector: DocumentSelector,
providerFunction: ProxyResult<
((params: TextDocumentPositionParams) => ProxySubscribable<Location[]>) & ProxyValue
>
): Unsubscribable & ProxyValue {
return proxyValue(
this.locationRegistry.registerProvider({ id, documentSelector }, params =>
wrapRemoteObservable(providerFunction(params))
)
)
}
public unsubscribe(): void {
this.subscriptions.unsubscribe()
}
}

View File

@ -1,20 +1,17 @@
import { ProxyResult } from '@sourcegraph/comlink'
import { WorkspaceRoot } from '@sourcegraph/extension-api-types'
import { Observable, Subscription } from 'rxjs'
import { createProxyAndHandleRequests } from '../../common/proxy'
import { ExtRootsAPI } from '../../extension/api/roots'
import { Connection } from '../../protocol/jsonrpc2/connection'
/** @internal */
export class ClientRoots {
private subscriptions = new Subscription()
private proxy: ExtRootsAPI
constructor(connection: Connection, modelRoots: Observable<WorkspaceRoot[] | null>) {
this.proxy = createProxyAndHandleRequests('roots', connection, this)
constructor(proxy: ProxyResult<ExtRootsAPI>, modelRoots: Observable<WorkspaceRoot[] | null>) {
this.subscriptions.add(
modelRoots.subscribe(roots => {
this.proxy.$acceptRoots(roots || [])
// tslint:disable-next-line: no-floating-promises
proxy.$acceptRoots(roots || [])
})
)
}

View File

@ -1,47 +1,25 @@
import { from, Observable, Subscription } from 'rxjs'
import { createProxyAndHandleRequests } from '../../common/proxy'
import { ExtSearch } from '../../extension/api/search'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { ProxyResult, ProxyValue, proxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { from } from 'rxjs'
import { QueryTransformer, Unsubscribable } from 'sourcegraph'
import { TransformQuerySignature } from '../services/queryTransformer'
import { FeatureProviderRegistry } from '../services/registry'
import { SubscriptionMap } from './common'
/** @internal */
export interface SearchAPI {
$registerQueryTransformer(id: number): void
$unregister(id: number): void
export interface ClientSearchAPI extends ProxyValue {
$registerQueryTransformer(transformer: ProxyResult<QueryTransformer & ProxyValue>): Unsubscribable & ProxyValue
}
/** @internal */
export class Search implements SearchAPI {
private subscriptions = new Subscription()
private registrations = new SubscriptionMap()
private proxy: ExtSearch
export class ClientSearch implements ClientSearchAPI, ProxyValue {
public readonly [proxyValueSymbol] = true
constructor(
connection: Connection,
private queryTransformerRegistry: FeatureProviderRegistry<{}, TransformQuerySignature>
) {
this.subscriptions.add(this.registrations)
constructor(private queryTransformerRegistry: FeatureProviderRegistry<{}, TransformQuerySignature>) {}
this.proxy = createProxyAndHandleRequests('search', connection, this)
}
public $registerQueryTransformer(id: number): void {
this.registrations.add(
id,
this.queryTransformerRegistry.registerProvider(
{},
(query: string): Observable<string> => from(this.proxy.$transformQuery(id, query))
)
public $registerQueryTransformer(
transformer: ProxyResult<QueryTransformer & ProxyValue>
): Unsubscribable & ProxyValue {
return proxyValue(
this.queryTransformerRegistry.registerProvider({}, query => from(transformer.transformQuery(query)))
)
}
public $unregister(id: number): void {
this.registrations.remove(id)
}
public unsubscribe(): void {
this.subscriptions.unsubscribe()
}
}

View File

@ -1,48 +1,36 @@
import { ReplaySubject, Subject, Subscription } from 'rxjs'
import { ProxyValue, proxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { ReplaySubject, Unsubscribable } from 'rxjs'
import { map } from 'rxjs/operators'
import { PanelView } from 'sourcegraph'
import { handleRequests } from '../../common/proxy'
import { ContributableViewContainer, TextDocumentPositionParams } from '../../protocol'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { TextDocumentLocationProviderIDRegistry } from '../services/location'
import { PanelViewWithComponent, ViewProviderRegistry } from '../services/view'
import { SubscriptionMap } from './common'
/** @internal */
export interface PanelViewData extends Pick<PanelView, 'title' | 'content' | 'priority' | 'component'> {}
export interface PanelUpdater extends Unsubscribable, ProxyValue {
update(data: PanelViewData): void
}
/** @internal */
export interface ClientViewsAPI {
$unregister(id: number): void
$registerPanelViewProvider(id: number, provider: { id: string }): void
$acceptPanelViewUpdate(id: number, params: Partial<PanelViewData>): void
export interface ClientViewsAPI extends ProxyValue {
$registerPanelViewProvider(provider: { id: string }): PanelUpdater
}
/** @internal */
export class ClientViews implements ClientViewsAPI {
private subscriptions = new Subscription()
private panelViews = new Map<number, Subject<PanelViewData>>()
private registrations = new SubscriptionMap()
public readonly [proxyValueSymbol] = true
constructor(
connection: Connection,
private viewRegistry: ViewProviderRegistry,
private textDocumentLocations: TextDocumentLocationProviderIDRegistry
) {
this.subscriptions.add(this.registrations)
) {}
handleRequests(connection, 'views', this)
}
public $unregister(id: number): void {
this.registrations.remove(id)
}
public $registerPanelViewProvider(id: number, provider: { id: string }): void {
public $registerPanelViewProvider(provider: { id: string }): PanelUpdater {
// TODO(sqs): This will probably hang forever if an extension neglects to set any of the fields on a
// PanelView because this subject will never emit.
const panelView = new ReplaySubject<PanelViewData>(1)
this.panelViews.set(id, panelView)
const registryUnsubscribable = this.viewRegistry.registerProvider(
{ ...provider, container: ContributableViewContainer.Panel },
panelView.pipe(
@ -60,23 +48,13 @@ export class ClientViews implements ClientViewsAPI {
)
)
)
this.registrations.add(id, {
return proxyValue({
update: (data: PanelViewData) => {
panelView.next(data)
},
unsubscribe: () => {
registryUnsubscribable.unsubscribe()
this.panelViews.delete(id)
},
})
}
public $acceptPanelViewUpdate(id: number, data: PanelViewData): void {
const panelView = this.panelViews.get(id)
if (panelView === undefined) {
throw new Error(`no panel view with ID ${id}`)
}
panelView.next(data)
}
public unsubscribe(): void {
this.subscriptions.unsubscribe()
}
}

View File

@ -1,8 +1,7 @@
import { ProxyResult, ProxyValue, proxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { Observable, Subject, Subscription } from 'rxjs'
import * as sourcegraph from 'sourcegraph'
import { createProxyAndHandleRequests } from '../../common/proxy'
import { ExtWindowsAPI } from '../../extension/api/windows'
import { Connection } from '../../protocol/jsonrpc2/connection'
import { ViewComponentData } from '../model'
import {
MessageActionItem,
@ -11,25 +10,23 @@ import {
ShowMessageParams,
ShowMessageRequestParams,
} from '../services/notifications'
import { SubscriptionMap } from './common'
/** @internal */
export interface ClientWindowsAPI {
export interface ClientWindowsAPI extends ProxyValue {
$showNotification(message: string): void
$showMessage(message: string): Promise<void>
$showInputBox(options?: sourcegraph.InputBoxOptions): Promise<string | undefined>
$startProgress(options: sourcegraph.ProgressOptions): Promise<number>
$updateProgress(handle: number, progress?: sourcegraph.Progress, error?: any, done?: boolean): void
$showProgress(options: sourcegraph.ProgressOptions): sourcegraph.ProgressReporter & ProxyValue
}
/** @internal */
export class ClientWindows implements ClientWindowsAPI {
public readonly [proxyValueSymbol] = true
private subscriptions = new Subscription()
private registrations = new SubscriptionMap()
private proxy: ExtWindowsAPI
constructor(
connection: Connection,
private proxy: ProxyResult<ExtWindowsAPI>,
modelVisibleViewComponents: Observable<ViewComponentData[] | null>,
/** Called when the client receives a window/showMessage notification. */
private showMessage: (params: ShowMessageParams) => void,
@ -45,10 +42,9 @@ export class ClientWindows implements ClientWindowsAPI {
private showInput: (params: ShowInputParams) => Promise<string | null>,
private createProgressReporter: (options: sourcegraph.ProgressOptions) => Subject<sourcegraph.Progress>
) {
this.proxy = createProxyAndHandleRequests('windows', connection, this)
this.subscriptions.add(
modelVisibleViewComponents.subscribe(viewComponents => {
// tslint:disable-next-line: no-floating-promises
this.proxy.$acceptWindowData(
viewComponents
? [
@ -64,12 +60,10 @@ export class ClientWindows implements ClientWindowsAPI {
)
})
)
this.subscriptions.add(this.registrations)
}
public $showNotification(message: string): void {
return this.showMessage({ type: MessageType.Info, message })
this.showMessage({ type: MessageType.Info, message })
}
public $showMessage(message: string): Promise<void> {
@ -92,29 +86,8 @@ export class ClientWindows implements ClientWindowsAPI {
)
}
private handles = 1
private progressReporters = new Map<number, Subject<sourcegraph.Progress>>()
public async $startProgress(options: sourcegraph.ProgressOptions): Promise<number> {
const handle = this.handles++
const reporter = this.createProgressReporter(options)
this.progressReporters.set(handle, reporter)
return handle
}
public $updateProgress(handle: number, progress?: sourcegraph.Progress, error?: any, done?: boolean): void {
const reporter = this.progressReporters.get(handle)
if (!reporter) {
console.warn('No ProgressReporter for handle ' + handle)
return
}
if (done || (progress && progress.percentage && progress.percentage >= 100)) {
reporter.complete()
} else if (error) {
reporter.error(error)
} else if (progress) {
reporter.next(progress)
}
public $showProgress(options: sourcegraph.ProgressOptions): sourcegraph.ProgressReporter & ProxyValue {
return proxyValue(this.createProgressReporter(options))
}
public unsubscribe(): void {

View File

@ -1,7 +1,8 @@
import { Observable, Subscription, Unsubscribable } from 'rxjs'
import { from, merge, Observable, Unsubscribable } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import { Connection } from '../protocol/jsonrpc2/connection'
import { createExtensionHostClientConnection, ExtensionHostClientConnection } from './connection'
import { EndpointPair } from '../../platform/context'
import { InitData } from '../extension/extensionHost'
import { createExtensionHostClientConnection } from './connection'
import { Services } from './services'
export interface ExtensionHostClient extends Unsubscribable {
@ -15,27 +16,20 @@ export interface ExtensionHostClient extends Unsubscribable {
/**
* Creates a client to communicate with an extension host.
*
* @param extensionHostConnection An observable that emits the connection to the extension host each time a new
* @param extensionHostEndpoint An observable that emits the connection to the extension host each time a new
* connection is established.
*/
export function createExtensionHostClient(
services: Services,
extensionHostConnection: Observable<Connection>
extensionHostEndpoint: Observable<EndpointPair>,
initData: InitData
): ExtensionHostClient {
const subscriptions = new Subscription()
const client = extensionHostConnection.pipe(
switchMap(connection => {
const client = createExtensionHostClientConnection(connection, services)
return new Observable<ExtensionHostClientConnection>(sub => {
sub.next(client)
return () => client.unsubscribe()
})
})
const client = extensionHostEndpoint.pipe(
switchMap(endpoints =>
from(createExtensionHostClientConnection(endpoints, services, initData)).pipe(
switchMap(client => merge([client], new Observable<never>(() => client)))
)
)
)
subscriptions.add(client.subscribe())
return {
unsubscribe: () => subscriptions.unsubscribe(),
}
return client.subscribe()
}

View File

@ -1,8 +1,12 @@
import * as comlink from '@sourcegraph/comlink'
import { isEqual } from 'lodash'
import { from, Subject, Subscription } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators'
import { ContextValues, Progress, ProgressOptions } from 'sourcegraph'
import { Connection } from '../protocol/jsonrpc2/connection'
import { Tracer } from '../protocol/jsonrpc2/trace'
import { ContextValues, Progress, ProgressOptions, Unsubscribable } from 'sourcegraph'
import { EndpointPair } from '../../platform/context'
import { ExtensionHostAPIFactory } from '../extension/api/api'
import { InitData } from '../extension/extensionHost'
import { ClientAPI } from './api/api'
import { ClientCodeEditor } from './api/codeEditor'
import { ClientCommands } from './api/commands'
import { ClientConfiguration } from './api/configuration'
@ -11,7 +15,7 @@ import { ClientDocuments } from './api/documents'
import { ClientExtensions } from './api/extensions'
import { ClientLanguageFeatures } from './api/languageFeatures'
import { ClientRoots } from './api/roots'
import { Search } from './api/search'
import { ClientSearch } from './api/search'
import { ClientViews } from './api/views'
import { ClientWindows } from './api/windows'
import { applyContextUpdate } from './context/context'
@ -24,12 +28,6 @@ import {
} from './services/notifications'
export interface ExtensionHostClientConnection {
/**
* Sets or unsets the tracer to use for logging all of this client's messages to/from the
* extension host.
*/
setTracer(tracer: Tracer | null): void
/**
* Closes the connection to and terminates the extension host.
*/
@ -51,85 +49,101 @@ export interface ActivatedExtension {
deactivate(): void | Promise<void>
}
export function createExtensionHostClientConnection(
connection: Connection,
services: Services
): ExtensionHostClientConnection {
/**
* @param endpoint The Worker object to communicate with
*/
export async function createExtensionHostClientConnection(
endpoints: EndpointPair,
services: Services,
initData: InitData
): Promise<Unsubscribable> {
const subscription = new Subscription()
connection.onRequest('ping', () => 'pong')
// MAIN THREAD
subscription.add(new ClientConfiguration<any>(connection, services.settings))
subscription.add(
new ClientContext(connection, (updates: ContextValues) =>
services.context.data.next(applyContextUpdate(services.context.data.value, updates))
)
/** Proxy to the exposed extension host API */
const initializeExtensionHost = comlink.proxy<ExtensionHostAPIFactory>(endpoints.proxy)
const proxy = await initializeExtensionHost(initData)
const clientConfiguration = new ClientConfiguration<any>(proxy.configuration, services.settings)
subscription.add(clientConfiguration)
const clientContext = new ClientContext((updates: ContextValues) =>
services.context.data.next(applyContextUpdate(services.context.data.value, updates))
)
subscription.add(
new ClientWindows(
connection,
from(services.model.model).pipe(
map(({ visibleViewComponents }) => visibleViewComponents),
distinctUntilChanged()
),
(params: ShowMessageParams) => services.notifications.showMessages.next({ ...params }),
(params: ShowMessageRequestParams) =>
new Promise<MessageActionItem | null>(resolve => {
services.notifications.showMessageRequests.next({ ...params, resolve })
}),
(params: ShowInputParams) =>
new Promise<string | null>(resolve => {
services.notifications.showInputs.next({ ...params, resolve })
}),
({ title }: ProgressOptions) => {
const reporter = new Subject<Progress>()
services.notifications.progresses.next({ title, progress: reporter.asObservable() })
return reporter
}
)
subscription.add(clientContext)
const clientWindows = new ClientWindows(
proxy.windows,
from(services.model.model).pipe(
map(({ visibleViewComponents }) => visibleViewComponents),
distinctUntilChanged()
),
(params: ShowMessageParams) => services.notifications.showMessages.next({ ...params }),
(params: ShowMessageRequestParams) =>
new Promise<MessageActionItem | null>(resolve => {
services.notifications.showMessageRequests.next({ ...params, resolve })
}),
(params: ShowInputParams) =>
new Promise<string | null>(resolve => {
services.notifications.showInputs.next({ ...params, resolve })
}),
({ title }: ProgressOptions) => {
const reporter = new Subject<Progress>()
services.notifications.progresses.next({ title, progress: reporter.asObservable() })
return reporter
}
)
subscription.add(new ClientViews(connection, services.views, services.textDocumentLocations))
subscription.add(new ClientCodeEditor(connection, services.textDocumentDecoration))
subscription.add(clientWindows)
const clientViews = new ClientViews(services.views, services.textDocumentLocations)
const clientCodeEditor = new ClientCodeEditor(services.textDocumentDecoration)
subscription.add(clientCodeEditor)
subscription.add(
new ClientDocuments(
connection,
proxy.documents,
from(services.model.model).pipe(
map(
({ visibleViewComponents }) =>
visibleViewComponents && visibleViewComponents.map(({ item }) => item)
),
distinctUntilChanged()
distinctUntilChanged((a, b) => isEqual(a, b))
)
)
)
subscription.add(
new ClientLanguageFeatures(
connection,
services.textDocumentHover,
services.textDocumentDefinition,
services.textDocumentTypeDefinition,
services.textDocumentImplementation,
services.textDocumentReferences,
services.textDocumentLocations
)
const clientLanguageFeatures = new ClientLanguageFeatures(
services.textDocumentHover,
services.textDocumentDefinition,
services.textDocumentTypeDefinition,
services.textDocumentImplementation,
services.textDocumentReferences,
services.textDocumentLocations
)
subscription.add(new Search(connection, services.queryTransformer))
subscription.add(new ClientCommands(connection, services.commands))
const clientSearch = new ClientSearch(services.queryTransformer)
const clientCommands = new ClientCommands(services.commands)
subscription.add(
new ClientRoots(
connection,
proxy.roots,
from(services.model.model).pipe(
map(({ roots }) => roots),
distinctUntilChanged()
)
)
)
subscription.add(new ClientExtensions(connection, services.extensions))
subscription.add(new ClientExtensions(proxy.extensions, services.extensions))
return {
setTracer: tracer => {
connection.trace(tracer)
},
unsubscribe: () => subscription.unsubscribe(),
const clientAPI: ClientAPI = {
ping: () => 'pong',
context: clientContext,
search: clientSearch,
configuration: clientConfiguration,
languageFeatures: clientLanguageFeatures,
commands: clientCommands,
windows: clientWindows,
codeEditor: clientCodeEditor,
views: clientViews,
}
comlink.expose(clientAPI, endpoints.expose)
return subscription
}

View File

@ -5,7 +5,7 @@ import { EMPTY_MODEL, Model } from '../model'
* The model service manages the model of documents and roots.
*/
export interface ModelService {
model: Subscribable<Model> & { value: Model } & NextObserver<Model>
readonly model: Subscribable<Model> & { readonly value: Model } & NextObserver<Model>
}
/**

View File

@ -1,5 +1,5 @@
import { Observable, of } from 'rxjs'
import { flatMap, map, switchMap } from 'rxjs/operators'
import { mergeMap, switchMap } from 'rxjs/operators'
import { FeatureProviderRegistry } from './registry'
export type TransformQuerySignature = (query: string) => Observable<string>
@ -24,9 +24,8 @@ export function transformQuery(providers: Observable<TransformQuerySignature[]>,
if (providers.length === 0) {
return [query]
}
return providers.reduce(
(currentQuery, transformQuery) =>
currentQuery.pipe(flatMap(q => transformQuery(q).pipe(map(transformedQuery => transformedQuery)))),
return providers.reduce<Observable<string>>(
(currentQuery, transformQuery) => currentQuery.pipe(mergeMap(q => transformQuery(q))),
of(query)
)
})

View File

@ -1,65 +0,0 @@
import { Observable } from 'rxjs'
import { bufferCount, first } from 'rxjs/operators'
import { createConnection } from '../protocol/jsonrpc2/connection'
import { createMessageTransports } from '../protocol/jsonrpc2/testHelpers'
import { createProxy, handleRequests } from './proxy'
function createTestProxy<T>(handler: T): Record<keyof T, (...args: any[]) => any> {
Object.setPrototypeOf(handler, null)
const [clientTransports, serverTransports] = createMessageTransports()
const client = createConnection(clientTransports)
const server = createConnection(serverTransports)
const proxy = createProxy(client, 'prefix')
handleRequests(server, 'prefix', handler)
client.listen()
server.listen()
return proxy
}
describe('Proxy', () => {
describe('proxies calls', () => {
const proxy = createTestProxy({
$a: (n: number) => n + 1,
$b: (n: number) => Promise.resolve(n + 2),
$c: (m: number, n: number) => m + n,
$d: (...args: number[]) => args.reduce((sum, n) => sum + n, 0),
$e: () => Promise.reject('e'),
$f: () => {
throw new Error('f')
},
})
test('to functions', async () => expect(proxy.$a(1)).resolves.toBe(2))
test('to async functions', async () => expect(proxy.$b(1)).resolves.toBe(3))
test('with multiple arguments ', async () => expect(proxy.$c(2, 3)).resolves.toBe(5))
test('with variadic arguments ', async () => expect(proxy.$d(...[2, 3, 4])).resolves.toBe(9))
test('to functions returning a rejected promise', async () =>
expect(proxy.$e()).rejects.toMatchObject({
message: 'Request prefix/$e failed unexpectedly without providing any details.',
}))
test('to functions throwing an error', async () => expect(proxy.$f()).rejects.toMatchObject({ message: 'f' }))
})
test('proxies Observables', async () => {
const proxy = createTestProxy({
$observe: (...args: number[]) =>
new Observable<number>(observer => {
for (const arg of args) {
observer.next(arg + 1)
}
observer.complete()
}),
})
await expect(
proxy
.$observe(1, 2, 3, 4)
.pipe(
bufferCount(4),
first()
)
.toPromise()
).resolves.toEqual([2, 3, 4, 5])
})
})

View File

@ -1,51 +0,0 @@
import { Connection } from '../protocol/jsonrpc2/connection'
/**
* @returns A proxy that translates method calls on itself to requests sent on the {@link connection}.
*/
export function createProxyAndHandleRequests(prefix: string, connection: Connection, handler: any): any {
handleRequests(connection, prefix, handler)
return createProxy(connection, prefix)
}
/**
* Creates a Proxy that translates method calls (whose name begins with "$") on the returned object to messages
* named `${prefix}/${name}` on the connection.
*
* @param connection - The connection to send messages on when proxy methods are called.
* @param prefix - The name prefix for connection methods.
*/
export function createProxy(connection: Connection, prefix: string): any {
return new Proxy(Object.create(null), {
get: (target: any, name: string) => {
if (!target[name] && name[0] === '$') {
const method = `${prefix}/${name}`
if (name.startsWith('$observe')) {
target[name] = (...args: any[]) => connection.observeRequest(method, args)
} else {
target[name] = (...args: any[]) => connection.sendRequest(method, args)
}
}
return target[name]
},
})
}
/**
* Forwards all requests received on the connection to the corresponding method on the handler object. The
* connection method `${prefix}/${name}` corresponds to the `${name}` method on the handler object.
*
* @param handler - An instance of a class whose methods should be called when the connection receives
* corresponding requests, or an object created with Object.create(null) (or otherwise with a null
* prototype) whose properties contain functions to be called.
*/
export function handleRequests(connection: Connection, prefix: string, handler: any): void {
// A class instance's methods are own, non-enumerable properties of its prototype.
const proto = Object.getPrototypeOf(handler) || handler
for (const name of Object.getOwnPropertyNames(proto)) {
const value = proto[name]
if (name[0] === '$' && typeof value === 'function') {
connection.onRequest(`${prefix}/${name}`, (args: any[]) => value.apply(handler, args))
}
}
}

View File

@ -0,0 +1,19 @@
import { ProxyValue } from '@sourcegraph/comlink'
import { InitData } from '../extensionHost'
import { ExtConfigurationAPI } from './configuration'
import { ExtDocumentsAPI } from './documents'
import { ExtExtensionsAPI } from './extensions'
import { ExtRootsAPI } from './roots'
import { ExtWindowsAPI } from './windows'
export type ExtensionHostAPIFactory = (initData: InitData) => ExtensionHostAPI
export interface ExtensionHostAPI extends ProxyValue {
ping(): 'pong'
documents: ExtDocumentsAPI
extensions: ExtExtensionsAPI
roots: ExtRootsAPI
windows: ExtWindowsAPI
configuration: ExtConfigurationAPI<any>
}

View File

@ -1,3 +1,4 @@
import { ProxyResult } from '@sourcegraph/comlink'
import * as clientType from '@sourcegraph/extension-api-types'
import { of } from 'rxjs'
import * as sourcegraph from 'sourcegraph'
@ -15,7 +16,7 @@ export class ExtCodeEditor implements sourcegraph.CodeEditor {
private resource: string,
public _selections: clientType.Selection[],
public readonly isActive: boolean,
private proxy: ClientCodeEditorAPI,
private proxy: ProxyResult<ClientCodeEditorAPI>,
private documents: ExtDocuments
) {}
@ -42,6 +43,7 @@ export class ExtCodeEditor implements sourcegraph.CodeEditor {
// Backcompat: extensions developed against an older version of the API
// may not supply a decorationType
decorationType = decorationType || DEFAULT_DECORATION_TYPE
// tslint:disable-next-line: no-floating-promises
this.proxy.$setDecorations(this.resource, decorationType.key, decorations.map(fromTextDocumentDecoration))
}

View File

@ -1,11 +1,7 @@
import { ProxyResult, proxyValue } from '@sourcegraph/comlink'
import { Unsubscribable } from 'rxjs'
import { ClientCommandsAPI } from '../../client/api/commands'
import { ProviderMap } from './common'
/** @internal */
export interface ExtCommandsAPI {
$executeCommand(id: number, args: any[]): Promise<any>
}
import { syncSubscription } from '../../util'
interface CommandEntry {
command: string
@ -13,16 +9,8 @@ interface CommandEntry {
}
/** @internal */
export class ExtCommands implements ExtCommandsAPI, Unsubscribable {
private registrations = new ProviderMap<CommandEntry>(id => this.proxy.$unregister(id))
constructor(private proxy: ClientCommandsAPI) {}
/** Proxy method invoked by the client when the client wants to executes a command. */
public $executeCommand(id: number, args: any[]): Promise<any> {
const { callback } = this.registrations.get<CommandEntry>(id)
return Promise.resolve(callback(...args))
}
export class ExtCommands {
constructor(private proxy: ProxyResult<ClientCommandsAPI>) {}
/**
* Extension API method invoked directly when an extension wants to execute a command. It calls to the client
@ -34,12 +22,6 @@ export class ExtCommands implements ExtCommandsAPI, Unsubscribable {
}
public registerCommand(entry: CommandEntry): Unsubscribable {
const { id, subscription } = this.registrations.add(entry)
this.proxy.$registerCommand(id, entry.command)
return subscription
}
public unsubscribe(): void {
this.registrations.unsubscribe()
return syncSubscription(this.proxy.$registerCommand(entry.command, proxyValue(entry.callback)))
}
}

View File

@ -1,99 +1,61 @@
import { from, isObservable, Observable, of, Subscribable as RxJSSubscribable } from 'rxjs'
import { ProxyResult, ProxyValue, proxyValue, proxyValueSymbol, UnproxyOrClone } from '@sourcegraph/comlink'
import { from, isObservable, Observable, Observer, of } from 'rxjs'
import { map } from 'rxjs/operators'
import { Subscribable, Unsubscribable } from 'sourcegraph'
import { ProviderResult, Subscribable, Unsubscribable } from 'sourcegraph'
import { isPromise, isSubscribable } from '../../util'
/**
* Manages a set of providers and associates a unique ID with each.
*
* @template B - The base provider type.
* @internal
* A Subscribable that can be exposed by comlink to the other thread.
* Only allows full object Observers to avoid complex type checking against proxies.
*/
export class ProviderMap<B> {
private idSequence = 0
private map = new Map<number, B>()
/**
* @param unsubscribeProvider - Callback to unsubscribe a provider.
*/
constructor(private unsubscribeProvider: (id: number) => void) {}
/**
* Adds a new provider.
*
* @param provider - The provider to add.
* @returns A newly allocated ID for the provider, unique among all other IDs in this map, and an
* unsubscribable for the provider.
* @throws If there already exists an entry with the given {@link id}.
*/
public add(provider: B): { id: number; subscription: Unsubscribable } {
const id = this.idSequence
this.map.set(id, provider)
this.idSequence++
return { id, subscription: { unsubscribe: () => this.remove(id) } }
}
/**
* Returns the provider with the given {@link id}.
*
* @template P - The specific provider type for the provider with this {@link id}.
* @throws If there is no entry with the given {@link id}.
*/
public get<P extends B>(id: number): P {
const provider = this.map.get(id) as P
if (provider === undefined) {
throw new Error(`no provider with ID ${id}`)
}
return provider
}
/**
* Unsubscribes the subscription that was previously assigned the given {@link id}, and removes it from the
* map.
*/
public remove(id: number): void {
if (!this.map.has(id)) {
throw new Error(`no provider with ID ${id}`)
}
try {
this.unsubscribeProvider(id)
} finally {
this.map.delete(id)
}
}
/**
* Unsubscribes all subscriptions in this map and clears it.
*/
public unsubscribe(): void {
try {
for (const id of this.map.keys()) {
this.unsubscribeProvider(id)
}
} finally {
this.map.clear()
}
}
export interface ProxySubscribable<T> extends ProxyValue {
subscribe(observer: ProxyResult<Observer<T> & ProxyValue>): Unsubscribable & ProxyValue
}
/**
* Returns an Observable that emits the provider result.
* Wraps a given Subscribable so that it is exposed by comlink to the other thread.
*
* @param subscribable A normal Subscribable (from this thread)
*/
export function toProviderResultObservable<T, R>(
result: Promise<T | undefined | null | Subscribable<T | undefined | null>>,
mapFunc: (value: T | undefined | null) => R | undefined | null
): Observable<R | undefined | null> {
return new Observable<R | undefined | null>(observer => {
result
.then(result => {
let observable: Observable<R | undefined | null>
if (result && (isPromise(result) || isObservable(result) || isSubscribable(result))) {
observable = from(result as Promise<any> | RxJSSubscribable<any>).pipe(map(mapFunc))
} else {
observable = of(mapFunc(result))
}
observable.subscribe(observer)
export const proxySubscribable = <T>(subscribable: Subscribable<T>): ProxySubscribable<T> => ({
[proxyValueSymbol]: true,
subscribe(observer): Unsubscribable & ProxyValue {
return proxyValue(
// Don't pass the proxy to Rx directly because it will try to
// access Symbol properties that cannot be proxied
subscribable.subscribe({
next: val => {
// tslint:disable-next-line: no-floating-promises
observer.next(val as UnproxyOrClone<T>)
},
error: err => {
// tslint:disable-next-line: no-floating-promises
observer.error(err)
},
complete: () => {
// tslint:disable-next-line: no-floating-promises
observer.complete()
},
})
.catch(err => observer.error(err))
})
)
},
})
/**
* Returns a Subscribable that can be proxied by comlink.
*
* @param result The result returned by the provider
* @param mapFunc A function to map the result into a value to be transmitted to the other thread
*/
export function toProxyableSubscribable<T, R>(
result: ProviderResult<T>,
mapFunc: (value: T | undefined | null) => R
): ProxySubscribable<R> {
let observable: Observable<R>
if (result && (isPromise(result) || isObservable<T>(result) || isSubscribable(result))) {
observable = from(result).pipe(map(mapFunc))
} else {
observable = of(mapFunc(result))
}
return proxySubscribable(observable)
}

View File

@ -1,3 +1,4 @@
import { ProxyResult, ProxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { BehaviorSubject, PartialObserver, Unsubscribable } from 'rxjs'
import * as sourcegraph from 'sourcegraph'
import { SettingsCascade } from '../../../settings/settings'
@ -8,7 +9,7 @@ import { ClientConfigurationAPI } from '../../client/api/configuration'
* @template C - The configuration schema.
*/
class ExtConfigurationSection<C extends object> implements sourcegraph.Configuration<C> {
constructor(private proxy: ClientConfigurationAPI, private data: C) {}
constructor(private proxy: ProxyResult<ClientConfigurationAPI>, private data: C) {}
public get<K extends keyof C>(key: K): C[K] | undefined {
return this.data[key]
@ -34,24 +35,26 @@ class ExtConfigurationSection<C extends object> implements sourcegraph.Configura
* @internal
* @template C - The configuration schema.
*/
export interface ExtConfigurationAPI<C> {
$acceptConfigurationData(data: Readonly<SettingsCascade<C>>): Promise<void>
export interface ExtConfigurationAPI<C> extends ProxyValue {
$acceptConfigurationData(data: Readonly<SettingsCascade<C>>): void
}
/**
* @internal
* @template C - The configuration schema.
*/
export class ExtConfiguration<C extends object> implements ExtConfigurationAPI<C> {
export class ExtConfiguration<C extends object> implements ExtConfigurationAPI<C>, ProxyValue {
public readonly [proxyValueSymbol] = true
/**
* The settings data observable, assigned when the initial data is received from the client. Extensions should
* never be able to call {@link ExtConfiguration}'s methods before the initial data is received.
*/
private data?: BehaviorSubject<Readonly<SettingsCascade<C>>>
constructor(private proxy: ClientConfigurationAPI) {}
constructor(private proxy: ProxyResult<ClientConfigurationAPI>) {}
public async $acceptConfigurationData(data: Readonly<SettingsCascade<C>>): Promise<void> {
public $acceptConfigurationData(data: Readonly<SettingsCascade<C>>): void {
if (!this.data) {
this.data = new BehaviorSubject(data)
} else {

View File

@ -1,11 +1,13 @@
import { ProxyResult } from '@sourcegraph/comlink'
import { ContextValues } from 'sourcegraph'
import { ClientContextAPI } from '../../client/api/context'
/** @internal */
export class ExtContext {
constructor(private proxy: ClientContextAPI) {}
constructor(private proxy: ProxyResult<ClientContextAPI>) {}
public updateContext(updates: ContextValues): void {
// tslint:disable-next-line: no-floating-promises
this.proxy.$acceptContextUpdates(updates)
}
}

View File

@ -1,14 +1,17 @@
import { ProxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { Subject } from 'rxjs'
import { TextDocument } from 'sourcegraph'
import { TextDocumentItem } from '../../client/types/textDocument'
/** @internal */
export interface ExtDocumentsAPI {
export interface ExtDocumentsAPI extends ProxyValue {
$acceptDocumentData(doc: TextDocumentItem[]): void
}
/** @internal */
export class ExtDocuments implements ExtDocumentsAPI {
export class ExtDocuments implements ExtDocumentsAPI, ProxyValue {
public readonly [proxyValueSymbol] = true
private documents = new Map<string, TextDocumentItem>()
constructor(private sync: () => Promise<void>) {}

View File

@ -1,9 +1,10 @@
import { ProxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { Subscription, Unsubscribable } from 'rxjs'
import { asError } from '../../../util/errors'
import { tryCatchPromise } from '../../util'
/** @internal */
export interface ExtExtensionsAPI {
export interface ExtExtensionsAPI extends ProxyValue {
$activateExtension(extensionID: string, bundleURL: string): Promise<void>
$deactivateExtension(extensionID: string): Promise<void>
}
@ -12,7 +13,9 @@ export interface ExtExtensionsAPI {
declare const self: any
/** @internal */
export class ExtExtensions implements ExtExtensionsAPI, Unsubscribable {
export class ExtExtensions implements ExtExtensionsAPI, Unsubscribable, ProxyValue {
public readonly [proxyValueSymbol] = true
/** Extensions' deactivate functions. */
private extensionDeactivate = new Map<string, (() => void | Promise<void>)>()

View File

@ -1,202 +1,100 @@
import { ProxyInput, ProxyResult, proxyValue } from '@sourcegraph/comlink'
import * as clientType from '@sourcegraph/extension-api-types'
import { Observable, Unsubscribable } from 'rxjs'
import { Unsubscribable } from 'rxjs'
import {
DefinitionProvider,
DocumentSelector,
Hover,
HoverProvider,
ImplementationProvider,
Location,
LocationProvider,
ReferenceContext,
ReferenceProvider,
Subscribable,
TypeDefinitionProvider,
} from 'sourcegraph'
import { ClientLanguageFeaturesAPI } from '../../client/api/languageFeatures'
import { ProviderMap, toProviderResultObservable } from './common'
import { ReferenceParams, TextDocumentPositionParams } from '../../protocol'
import { syncSubscription } from '../../util'
import { toProxyableSubscribable } from './common'
import { ExtDocuments } from './documents'
import { fromHover, fromLocation, toPosition } from './types'
/** @internal */
export interface ExtLanguageFeaturesAPI {
$observeHover(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Hover | null | undefined>
$observeDefinition(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Location[] | null | undefined>
$observeTypeDefinition(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Location[] | null | undefined>
$observeImplementation(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Location[] | null | undefined>
$observeReferences(
id: number,
resource: string,
position: clientType.Position,
context: ReferenceContext
): Observable<clientType.Location[] | null | undefined>
$observeLocations(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Location[] | null | undefined>
}
/** @internal */
export class ExtLanguageFeatures implements ExtLanguageFeaturesAPI, Unsubscribable {
private registrations = new ProviderMap<
| HoverProvider
| DefinitionProvider
| TypeDefinitionProvider
| ImplementationProvider
| ReferenceProvider
| LocationProvider
>(id => this.proxy.$unregister(id))
constructor(private proxy: ClientLanguageFeaturesAPI, private documents: ExtDocuments) {}
public $observeHover(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Hover | null | undefined> {
const provider = this.registrations.get<HoverProvider>(id)
return toProviderResultObservable(
this.documents
.getSync(resource)
.then<Hover | undefined | null | Subscribable<Hover | undefined | null>>(document =>
provider.provideHover(document, toPosition(position))
),
hover => (hover ? fromHover(hover) : hover)
)
}
export class ExtLanguageFeatures {
constructor(private proxy: ProxyResult<ClientLanguageFeaturesAPI>, private documents: ExtDocuments) {}
public registerHoverProvider(selector: DocumentSelector, provider: HoverProvider): Unsubscribable {
const { id, subscription } = this.registrations.add(provider)
this.proxy.$registerHoverProvider(id, selector)
return subscription
}
public $observeDefinition(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Location[] | null | undefined> {
const provider = this.registrations.get<DefinitionProvider>(id)
return toProviderResultObservable(
this.documents
.getSync(resource)
.then<
Location | Location[] | null | undefined | Subscribable<Location | Location[] | null | undefined>
>(document => provider.provideDefinition(document, toPosition(position))),
toDefinition
const providerFunction: ProxyInput<
Parameters<ClientLanguageFeaturesAPI['$registerHoverProvider']>[1]
> = proxyValue(async ({ textDocument, position }: TextDocumentPositionParams) =>
toProxyableSubscribable(
provider.provideHover(await this.documents.getSync(textDocument.uri), toPosition(position)),
hover => (hover ? fromHover(hover) : hover)
)
)
return syncSubscription(this.proxy.$registerHoverProvider(selector, providerFunction))
}
public registerDefinitionProvider(selector: DocumentSelector, provider: DefinitionProvider): Unsubscribable {
const { id, subscription } = this.registrations.add(provider)
this.proxy.$registerDefinitionProvider(id, selector)
return subscription
}
public $observeTypeDefinition(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Location[] | null | undefined> {
const provider = this.registrations.get<TypeDefinitionProvider>(id)
return toProviderResultObservable(
this.documents
.getSync(resource)
.then<
Location | Location[] | null | undefined | Subscribable<Location | Location[] | null | undefined>
>(document => provider.provideTypeDefinition(document, toPosition(position))),
toDefinition
const providerFunction: ProxyInput<
Parameters<ClientLanguageFeaturesAPI['$registerDefinitionProvider']>[1]
> = proxyValue(async ({ textDocument, position }: TextDocumentPositionParams) =>
toProxyableSubscribable(
provider.provideDefinition(await this.documents.getSync(textDocument.uri), toPosition(position)),
toLocations
)
)
return syncSubscription(this.proxy.$registerDefinitionProvider(selector, providerFunction))
}
public registerTypeDefinitionProvider(
selector: DocumentSelector,
provider: TypeDefinitionProvider
): Unsubscribable {
const { id, subscription } = this.registrations.add(provider)
this.proxy.$registerTypeDefinitionProvider(id, selector)
return subscription
}
public $observeImplementation(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Location[] | null | undefined> {
const provider = this.registrations.get<ImplementationProvider>(id)
return toProviderResultObservable(
this.documents
.getSync(resource)
.then<
Location | Location[] | null | undefined | Subscribable<Location | Location[] | null | undefined>
>(document => provider.provideImplementation(document, toPosition(position))),
toDefinition
const providerFunction: ProxyInput<
Parameters<ClientLanguageFeaturesAPI['$registerTypeDefinitionProvider']>[1]
> = proxyValue(async ({ textDocument, position }: TextDocumentPositionParams) =>
toProxyableSubscribable(
provider.provideTypeDefinition(await this.documents.getSync(textDocument.uri), toPosition(position)),
toLocations
)
)
return syncSubscription(this.proxy.$registerTypeDefinitionProvider(selector, providerFunction))
}
public registerImplementationProvider(
selector: DocumentSelector,
provider: ImplementationProvider
): Unsubscribable {
const { id, subscription } = this.registrations.add(provider)
this.proxy.$registerImplementationProvider(id, selector)
return subscription
}
public $observeReferences(
id: number,
resource: string,
position: clientType.Position,
context: ReferenceContext
): Observable<clientType.Location[] | null | undefined> {
const provider = this.registrations.get<ReferenceProvider>(id)
return toProviderResultObservable(
this.documents
.getSync(resource)
.then<Location[] | null | undefined | Subscribable<Location[] | null | undefined>>(document =>
provider.provideReferences(document, toPosition(position), context)
),
toLocations
return syncSubscription(
this.proxy.$registerImplementationProvider(
selector,
proxyValue(async ({ textDocument, position }: TextDocumentPositionParams) =>
toProxyableSubscribable(
provider.provideImplementation(
await this.documents.getSync(textDocument.uri),
toPosition(position)
),
toLocations
)
)
)
)
}
public registerReferenceProvider(selector: DocumentSelector, provider: ReferenceProvider): Unsubscribable {
const { id, subscription } = this.registrations.add(provider)
this.proxy.$registerReferenceProvider(id, selector)
return subscription
}
public $observeLocations(
id: number,
resource: string,
position: clientType.Position
): Observable<clientType.Location[] | null | undefined> {
const provider = this.registrations.get<LocationProvider>(id)
return toProviderResultObservable(
this.documents
.getSync(resource)
.then<Location[] | null | undefined | Subscribable<Location[] | null | undefined>>(document =>
provider.provideLocations(document, toPosition(position))
const providerFunction: ProxyInput<
Parameters<ClientLanguageFeaturesAPI['$registerReferenceProvider']>[1]
> = proxyValue(async ({ textDocument, position, context }: ReferenceParams) =>
toProxyableSubscribable(
provider.provideReferences(
await this.documents.getSync(textDocument.uri),
toPosition(position),
context
),
toLocations
toLocations
)
)
return syncSubscription(this.proxy.$registerReferenceProvider(selector, providerFunction))
}
public registerLocationProvider(
@ -204,25 +102,18 @@ export class ExtLanguageFeatures implements ExtLanguageFeaturesAPI, Unsubscribab
selector: DocumentSelector,
provider: LocationProvider
): Unsubscribable {
/**
* {@link idStr} is the `id` parameter to {@link sourcegraph.languages.registerLocationProvider} that
* identifies the provider and is chosen by the extension. {@link id} is an internal implementation detail:
* the numeric registry ID used to identify this provider solely between the client and extension host.
*/
const { id, subscription } = this.registrations.add(provider)
this.proxy.$registerLocationProvider(id, idStr, selector)
return subscription
}
public unsubscribe(): void {
this.registrations.unsubscribe()
const providerFunction: ProxyInput<
Parameters<ClientLanguageFeaturesAPI['$registerLocationProvider']>[2]
> = proxyValue(async ({ textDocument, position }: TextDocumentPositionParams) =>
toProxyableSubscribable(
provider.provideLocations(await this.documents.getSync(textDocument.uri), toPosition(position)),
toLocations
)
)
return syncSubscription(this.proxy.$registerLocationProvider(idStr, selector, proxyValue(providerFunction)))
}
}
function toLocations(result: Location[] | null | undefined): clientType.Location[] | null | undefined {
return result ? result.map(location => fromLocation(location)) : result
}
function toDefinition(result: Location[] | Location | null | undefined): clientType.Location[] | null | undefined {
return result ? (Array.isArray(result) ? result : [result]).map(location => fromLocation(location)) : result
function toLocations(result: Location[] | Location | null | undefined): clientType.Location[] {
return result ? (Array.isArray(result) ? result : [result]).map(location => fromLocation(location)) : []
}

View File

@ -1,15 +1,18 @@
import { ProxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import * as clientType from '@sourcegraph/extension-api-types'
import { Subject } from 'rxjs'
import * as sourcegraph from 'sourcegraph'
import { URI } from '../types/uri'
/** @internal */
export interface ExtRootsAPI {
export interface ExtRootsAPI extends ProxyValue {
$acceptRoots(roots: clientType.WorkspaceRoot[]): void
}
/** @internal */
export class ExtRoots implements ExtRootsAPI {
export class ExtRoots implements ExtRootsAPI, ProxyValue {
public readonly [proxyValueSymbol] = true
private roots: ReadonlyArray<sourcegraph.WorkspaceRoot> = []
/**
@ -24,9 +27,7 @@ export class ExtRoots implements ExtRootsAPI {
public readonly changes = new Subject<void>()
public $acceptRoots(roots: clientType.WorkspaceRoot[]): void {
this.roots = Object.freeze(
roots.map(plain => ({ ...plain, uri: new URI(plain.uri) } as sourcegraph.WorkspaceRoot))
)
this.roots = Object.freeze(roots.map(plain => ({ ...plain, uri: new URI(plain.uri) })))
this.changes.next()
}
}

View File

@ -1,28 +1,13 @@
import { ProxyResult, proxyValue } from '@sourcegraph/comlink'
import { Unsubscribable } from 'rxjs'
import { QueryTransformer } from 'sourcegraph'
import { SearchAPI } from '../../client/api/search'
import { ProviderMap } from './common'
import { ClientSearchAPI } from '../../client/api/search'
import { syncSubscription } from '../../util'
export interface ExtSearchAPI {
$transformQuery: (id: number, query: string) => Promise<string>
}
export class ExtSearch implements ExtSearchAPI, Unsubscribable {
private registrations = new ProviderMap<QueryTransformer>(id => this.proxy.$unregister(id))
constructor(private proxy: SearchAPI) {}
export class ExtSearch {
constructor(private proxy: ProxyResult<ClientSearchAPI>) {}
public registerQueryTransformer(provider: QueryTransformer): Unsubscribable {
const { id, subscription } = this.registrations.add(provider)
this.proxy.$registerQueryTransformer(id)
return subscription
}
public $transformQuery(id: number, query: string): Promise<string> {
const provider = this.registrations.get<QueryTransformer>(id)
return Promise.resolve(provider.transformQuery(query))
}
public unsubscribe(): void {
this.registrations.unsubscribe()
return syncSubscription(this.proxy.$registerQueryTransformer(proxyValue(provider)))
}
}

View File

@ -1,7 +1,6 @@
import { Unsubscribable } from 'rxjs'
import { ProxyResult, ProxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import * as sourcegraph from 'sourcegraph'
import { ClientViewsAPI, PanelViewData } from '../../client/api/views'
import { ProviderMap } from './common'
import { ClientViewsAPI, PanelUpdater, PanelViewData } from '../../client/api/views'
/**
* @internal
@ -14,7 +13,7 @@ class ExtPanelView implements sourcegraph.PanelView {
component: null,
}
constructor(private proxy: ClientViewsAPI, private id: number, private subscription: Unsubscribable) {}
constructor(private proxyPromise: Promise<ProxyResult<PanelUpdater>>) {}
public get title(): string {
return this.data.title
@ -49,27 +48,24 @@ class ExtPanelView implements sourcegraph.PanelView {
}
private sendData(): void {
this.proxy.$acceptPanelViewUpdate(this.id, this.data)
// tslint:disable-next-line: no-floating-promises
this.proxyPromise.then(proxy => proxy.update(this.data))
}
public unsubscribe(): void {
return this.subscription.unsubscribe()
// tslint:disable-next-line: no-floating-promises
this.proxyPromise.then(proxy => proxy.unsubscribe())
}
}
/** @internal */
export class ExtViews implements Unsubscribable {
private registrations = new ProviderMap<{}>(id => this.proxy.$unregister(id))
export class ExtViews implements ProxyValue {
public readonly [proxyValueSymbol] = true
constructor(private proxy: ClientViewsAPI) {}
constructor(private proxy: ProxyResult<ClientViewsAPI>) {}
public createPanelView(id: string): ExtPanelView {
const { id: regID, subscription } = this.registrations.add({})
this.proxy.$registerPanelViewProvider(regID, { id })
return new ExtPanelView(this.proxy, regID, subscription)
}
public unsubscribe(): void {
this.registrations.unsubscribe()
const panelProxyPromise = this.proxy.$registerPanelViewProvider({ id })
return new ExtPanelView(panelProxyPromise)
}
}

View File

@ -1,4 +1,5 @@
import { BehaviorSubject, Observer, of } from 'rxjs'
import { ProxyResult, ProxyValue, proxyValueSymbol } from '@sourcegraph/comlink'
import { BehaviorSubject, of } from 'rxjs'
import * as sourcegraph from 'sourcegraph'
import { asError } from '../../../util/errors'
import { ClientCodeEditorAPI } from '../../client/api/codeEditor'
@ -17,7 +18,7 @@ export interface WindowData {
* @internal
*/
class ExtWindow implements sourcegraph.Window {
constructor(private windowsProxy: ClientWindowsAPI, private readonly textEditors: ExtCodeEditor[]) {}
constructor(private windowsProxy: ProxyResult<ClientWindowsAPI>, private readonly textEditors: ExtCodeEditor[]) {}
public readonly activeViewComponentChanges = of(this.activeViewComponent)
@ -30,6 +31,7 @@ class ExtWindow implements sourcegraph.Window {
}
public showNotification(message: string): void {
// tslint:disable-next-line: no-floating-promises
this.windowsProxy.$showNotification(message)
}
@ -57,23 +59,26 @@ class ExtWindow implements sourcegraph.Window {
}
public async showProgress(options: sourcegraph.ProgressOptions): Promise<sourcegraph.ProgressReporter> {
const handle = await this.windowsProxy.$startProgress(options)
const reporter: Observer<sourcegraph.Progress> = {
next: (progress: sourcegraph.Progress): void => {
this.windowsProxy.$updateProgress(handle, progress)
const reporterProxy = await this.windowsProxy.$showProgress(options)
return {
next: progress => {
// tslint:disable-next-line: no-floating-promises
reporterProxy.next(progress)
},
error: (err: any): void => {
error: err => {
const error = asError(err)
this.windowsProxy.$updateProgress(handle, undefined, {
// tslint:disable-next-line: no-floating-promises
reporterProxy.error({
message: error.message,
name: error.name,
stack: error.stack,
})
},
complete: (): void => {
this.windowsProxy.$updateProgress(handle, undefined, undefined, true)
complete: () => {
// tslint:disable-next-line: no-floating-promises
reporterProxy.complete()
},
}
return reporter
}
public toJSON(): any {
@ -82,18 +87,19 @@ class ExtWindow implements sourcegraph.Window {
}
/** @internal */
export interface ExtWindowsAPI {
export interface ExtWindowsAPI extends ProxyValue {
$acceptWindowData(allWindows: WindowData[]): void
}
/** @internal */
export class ExtWindows implements ExtWindowsAPI {
export class ExtWindows implements ExtWindowsAPI, ProxyValue {
public readonly [proxyValueSymbol] = true
private data: WindowData[] = []
/** @internal */
constructor(
private windowsProxy: ClientWindowsAPI,
private codeEditorProxy: ClientCodeEditorAPI,
private proxy: ProxyResult<{ windows: ClientWindowsAPI; codeEditor: ClientCodeEditorAPI }>,
private documents: ExtDocuments
) {}
@ -113,14 +119,14 @@ export class ExtWindows implements ExtWindowsAPI {
return this.data.map(
window =>
new ExtWindow(
this.windowsProxy,
this.proxy.windows,
window.visibleViewComponents.map(
c =>
new ExtCodeEditor(
c.item.uri,
c.selections,
c.isActive,
this.codeEditorProxy,
this.proxy.codeEditor,
this.documents
)
)

View File

@ -1,7 +1,9 @@
import * as comlink from '@sourcegraph/comlink'
import { Subscription, Unsubscribable } from 'rxjs'
import * as sourcegraph from 'sourcegraph'
import { createProxy, handleRequests } from '../common/proxy'
import { Connection, createConnection, Logger, MessageTransports } from '../protocol/jsonrpc2/connection'
import { EndpointPair } from '../../platform/context'
import { ClientAPI } from '../client/api/api'
import { ExtensionHostAPI, ExtensionHostAPIFactory } from './api/api'
import { ExtCommands } from './api/commands'
import { ExtConfiguration } from './api/configuration'
import { ExtContext } from './api/context'
@ -19,8 +21,6 @@ import { Range } from './types/range'
import { Selection } from './types/selection'
import { URI } from './types/uri'
const consoleLogger: Logger = console
/**
* Required information when initializing an extension host.
*/
@ -44,30 +44,31 @@ export interface InitData {
* @return An unsubscribable to terminate the extension host.
*/
export function startExtensionHost(
transports: MessageTransports
): Unsubscribable & { __testAPI: Promise<typeof sourcegraph> } {
const connection = createConnection(transports, consoleLogger)
connection.listen()
endpoints: EndpointPair
): Unsubscribable & { extensionAPI: Promise<typeof sourcegraph> } {
const subscription = new Subscription()
subscription.add(connection)
// Wait for "initialize" message from client application before proceeding to create the
// extension host.
let initialized = false
const __testAPI = new Promise<typeof sourcegraph>(resolve => {
connection.onRequest('initialize', ([initData]: [InitData]) => {
const extensionAPI = new Promise<typeof sourcegraph>(resolve => {
const factory: ExtensionHostAPIFactory = initData => {
if (initialized) {
throw new Error('extension host is already initialized')
}
initialized = true
const { unsubscribe, __testAPI } = initializeExtensionHost(connection, initData)
subscription.add(unsubscribe)
resolve(__testAPI)
})
const { subscription: extHostSubscription, extensionAPI, extensionHostAPI } = initializeExtensionHost(
endpoints,
initData
)
subscription.add(extHostSubscription)
resolve(extensionAPI)
return extensionHostAPI
}
comlink.expose(factory, endpoints.expose)
})
return { unsubscribe: () => subscription.unsubscribe(), __testAPI }
return { unsubscribe: () => subscription.unsubscribe(), extensionAPI }
}
/**
@ -82,24 +83,24 @@ export function startExtensionHost(
* @return An unsubscribable to terminate the extension host.
*/
function initializeExtensionHost(
connection: Connection,
endpoints: EndpointPair,
initData: InitData
): Unsubscribable & { __testAPI: typeof sourcegraph } {
const subscriptions = new Subscription()
): { extensionHostAPI: ExtensionHostAPI; extensionAPI: typeof sourcegraph; subscription: Subscription } {
const subscription = new Subscription()
const { api, subscription: apiSubscription } = createExtensionAPI(initData, connection)
subscriptions.add(apiSubscription)
const { extensionAPI, extensionHostAPI, subscription: apiSubscription } = createExtensionAPI(initData, endpoints)
subscription.add(apiSubscription)
// Make `import 'sourcegraph'` or `require('sourcegraph')` return the extension API.
;(global as any).require = (modulePath: string): any => {
if (modulePath === 'sourcegraph') {
return api
return extensionAPI
}
// All other requires/imports in the extension's code should not reach here because their JS
// bundler should have resolved them locally.
throw new Error(`require: module not found: ${modulePath}`)
}
subscriptions.add(() => {
subscription.add(() => {
;(global as any).require = () => {
// Prevent callers from attempting to access the extension API after it was
// unsubscribed.
@ -107,55 +108,52 @@ function initializeExtensionHost(
}
})
return { unsubscribe: () => subscriptions.unsubscribe(), __testAPI: api }
return { subscription, extensionAPI, extensionHostAPI }
}
function createExtensionAPI(
initData: InitData,
connection: Connection
): { api: typeof sourcegraph; subscription: Subscription } {
const subscriptions = new Subscription()
endpoints: Pick<EndpointPair, 'proxy'>
): { extensionHostAPI: ExtensionHostAPI; extensionAPI: typeof sourcegraph; subscription: Subscription } {
const subscription = new Subscription()
// EXTENSION HOST WORKER
/** Proxy to main thread */
const proxy = comlink.proxy<ClientAPI>(endpoints.proxy)
// For debugging/tests.
const sync = () => connection.sendRequest<void>('ping')
connection.onRequest('ping', () => 'pong')
const context = new ExtContext(createProxy(connection, 'context'))
handleRequests(connection, 'context', context)
const sync = async () => {
await proxy.ping()
}
const context = new ExtContext(proxy.context)
const documents = new ExtDocuments(sync)
handleRequests(connection, 'documents', documents)
const extensions = new ExtExtensions()
subscriptions.add(extensions)
handleRequests(connection, 'extensions', extensions)
subscription.add(extensions)
const roots = new ExtRoots()
handleRequests(connection, 'roots', roots)
const windows = new ExtWindows(proxy, documents)
const views = new ExtViews(proxy.views)
const configuration = new ExtConfiguration<any>(proxy.configuration)
const languageFeatures = new ExtLanguageFeatures(proxy.languageFeatures, documents)
const search = new ExtSearch(proxy.search)
const commands = new ExtCommands(proxy.commands)
const windows = new ExtWindows(createProxy(connection, 'windows'), createProxy(connection, 'codeEditor'), documents)
handleRequests(connection, 'windows', windows)
// Expose the extension host API to the client (main thread)
const extensionHostAPI: ExtensionHostAPI = {
[comlink.proxyValueSymbol]: true,
const views = new ExtViews(createProxy(connection, 'views'))
subscriptions.add(views)
handleRequests(connection, 'views', views)
ping: () => 'pong',
configuration,
documents,
extensions,
roots,
windows,
}
const configuration = new ExtConfiguration<any>(createProxy(connection, 'configuration'))
handleRequests(connection, 'configuration', configuration)
const languageFeatures = new ExtLanguageFeatures(createProxy(connection, 'languageFeatures'), documents)
subscriptions.add(languageFeatures)
handleRequests(connection, 'languageFeatures', languageFeatures)
const search = new ExtSearch(createProxy(connection, 'search'))
subscriptions.add(search)
handleRequests(connection, 'search', search)
const commands = new ExtCommands(createProxy(connection, 'commands'))
subscriptions.add(commands)
handleRequests(connection, 'commands', commands)
const api: typeof sourcegraph = {
// Expose the extension API to extensions
const extensionAPI: typeof sourcegraph = {
URI,
Position,
Range,
@ -251,5 +249,5 @@ function createExtensionAPI(
clientApplication: initData.clientApplication,
},
}
return { api, subscription: subscriptions }
return { extensionHostAPI, extensionAPI, subscription }
}

View File

@ -1,26 +1,64 @@
// The ponyfill symbol-observable is impure. Since extensions are loaded through importScripts,
// if one of our extensions depends on symbol-observable, it may break other extensions:
// https://github.com/sourcegraph/sourcegraph/issues/1243
// Importing symbol-observable when starting the web worker fixes this by
// ensuring that `Symbol.observable` is mutated happens before any extensions are loaded.
import 'symbol-observable'
import { createWebWorkerMessageTransports } from '../protocol/jsonrpc2/transports/webWorker'
import '../../polyfills'
import * as MessageChannelAdapter from '@sourcegraph/comlink/messagechanneladapter'
import { fromEvent } from 'rxjs'
import { take } from 'rxjs/operators'
import { EndpointPair, isEndpointPair } from '../../platform/context'
import { startExtensionHost } from './extensionHost'
export interface InitMessage {
endpoints: {
proxy: MessagePort
expose: MessagePort
}
/**
* Whether the endpoints should be wrapped with a comlink {@link MessageChannelAdapter}.
*
* This is true when the messages passed on the endpoints are forwarded to/from
* other wrapped endpoints, like in the browser extension.
*/
wrapEndpoints: boolean
}
const isInitMessage = (value: any): value is InitMessage => value.endpoints && isEndpointPair(value.endpoints)
const wrapMessagePort = (port: MessagePort) =>
MessageChannelAdapter.wrap({
send: data => port.postMessage(data),
addEventListener: (event, listener) => port.addEventListener(event, listener),
removeEventListener: (event, listener) => port.removeEventListener(event, listener),
})
const wrapEndpoints = ({ proxy, expose }: InitMessage['endpoints']): EndpointPair => {
proxy.start()
expose.start()
return {
proxy: wrapMessagePort(proxy),
expose: wrapMessagePort(expose),
}
}
/**
* The entrypoint for the JavaScript context that runs the extension host (and all extensions).
*
* To initialize the extension host, the parent sends it an "initialize" message with
* {@link InitData}.
* To initialize the extension host, the parent sends it an {@link InitMessage}
*/
function extensionHostMain(): void {
async function extensionHostMain(): Promise<void> {
try {
const { unsubscribe } = startExtensionHost(createWebWorkerMessageTransports())
self.addEventListener('unload', () => unsubscribe())
const event = await fromEvent<MessageEvent>(self, 'message')
.pipe(take(1))
.toPromise()
if (!isInitMessage(event.data)) {
throw new Error('First message event in extension host worker was not a well-formed InitMessage')
}
const { endpoints } = event.data
const extensionHost = startExtensionHost(event.data.wrapEndpoints ? wrapEndpoints(endpoints) : endpoints)
self.addEventListener('unload', () => extensionHost.unsubscribe())
} catch (err) {
console.error('Error starting the extension host:', err)
self.close()
}
}
// tslint:disable-next-line: no-floating-promises
extensionHostMain()

View File

@ -0,0 +1,39 @@
import { Observable } from 'rxjs'
import ExtensionHostWorker from 'worker-loader?inline!./main.worker.ts'
import { EndpointPair } from '../../platform/context'
interface ExtensionHostInitOptions {
/**
* Whether the endpoints should be wrapped with a comlink {@link MessageChannelAdapter}.
*
* This is true when the messages passed on the endpoints are forwarded to/from
* other wrapped endpoints, like in the browser extension.
*/
wrapEndpoints: boolean
}
export function createExtensionHostWorker({
wrapEndpoints,
}: ExtensionHostInitOptions): { worker: ExtensionHostWorker; clientEndpoints: EndpointPair } {
const clientAPIChannel = new MessageChannel()
const extensionHostAPIChannel = new MessageChannel()
const worker = new ExtensionHostWorker()
const workerEndpoints: EndpointPair = {
proxy: clientAPIChannel.port2,
expose: extensionHostAPIChannel.port2,
}
worker.postMessage({ endpoints: workerEndpoints, wrapEndpoints }, Object.values(workerEndpoints))
const clientEndpoints = {
proxy: extensionHostAPIChannel.port1,
expose: clientAPIChannel.port1,
}
return { worker, clientEndpoints }
}
export function createExtensionHost({ wrapEndpoints }: ExtensionHostInitOptions): Observable<EndpointPair> {
return new Observable(subscriber => {
const { clientEndpoints, worker } = createExtensionHostWorker({ wrapEndpoints })
subscriber.next(clientEndpoints)
return () => worker.terminate()
})
}

View File

@ -6,18 +6,18 @@ import { integrationTestContext } from './testHelpers'
describe('CodeEditor (integration)', () => {
describe('setDecorations', () => {
test('adds decorations', async () => {
const { services, extensionHost } = await integrationTestContext()
const dt = extensionHost.app.createDecorationType()
const { services, extensionAPI } = await integrationTestContext()
const dt = extensionAPI.app.createDecorationType()
// Set some decorations and check they are present on the client.
const codeEditor = extensionHost.app.windows[0].visibleViewComponents[0]
const codeEditor = extensionAPI.app.windows[0].visibleViewComponents[0]
codeEditor.setDecorations(dt, [
{
range: new Range(1, 2, 3, 4),
backgroundColor: 'red',
},
])
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(
await services.textDocumentDecoration
.getDecorations({ uri: 'file:///f' })
@ -32,7 +32,7 @@ describe('CodeEditor (integration)', () => {
// Clear the decorations and ensure they are removed.
codeEditor.setDecorations(dt, [])
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(
await services.textDocumentDecoration
.getDecorations({ uri: 'file:///f' })
@ -42,10 +42,10 @@ describe('CodeEditor (integration)', () => {
})
it('merges decorations from several types', async () => {
const { services, extensionHost } = await integrationTestContext()
const [dt1, dt2] = [extensionHost.app.createDecorationType(), extensionHost.app.createDecorationType()]
const { services, extensionAPI } = await integrationTestContext()
const [dt1, dt2] = [extensionAPI.app.createDecorationType(), extensionAPI.app.createDecorationType()]
const codeEditor = extensionHost.app.windows[0].visibleViewComponents[0]
const codeEditor = extensionAPI.app.windows[0].visibleViewComponents[0]
codeEditor.setDecorations(dt1, [
{
range: new Range(1, 2, 3, 4),
@ -62,7 +62,7 @@ describe('CodeEditor (integration)', () => {
},
},
])
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(
await services.textDocumentDecoration
.getDecorations({ uri: 'file:///f' })
@ -92,7 +92,7 @@ describe('CodeEditor (integration)', () => {
},
},
])
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(
await services.textDocumentDecoration
.getDecorations({ uri: 'file:///f' })
@ -115,7 +115,7 @@ describe('CodeEditor (integration)', () => {
// remove decorations for dt2, and verify that decorations for dt1 are still present
codeEditor.setDecorations(dt2, [])
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(
await services.textDocumentDecoration
.getDecorations({ uri: 'file:///f' })
@ -132,11 +132,11 @@ describe('CodeEditor (integration)', () => {
})
it('is backwards compatible with extensions that do not provide a decoration type', async () => {
const { services, extensionHost } = await integrationTestContext()
const dt = extensionHost.app.createDecorationType()
const { services, extensionAPI } = await integrationTestContext()
const dt = extensionAPI.app.createDecorationType()
// Set some decorations and check they are present on the client.
const codeEditor = extensionHost.app.windows[0].visibleViewComponents[0]
const codeEditor = extensionAPI.app.windows[0].visibleViewComponents[0]
codeEditor.setDecorations(dt, [
{
range: new Range(1, 2, 3, 4),
@ -156,7 +156,7 @@ describe('CodeEditor (integration)', () => {
])
// Both sets of decorations should be displayed
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(
await services.textDocumentDecoration
.getDecorations({ uri: 'file:///f' })

View File

@ -3,33 +3,33 @@ import { integrationTestContext } from './testHelpers'
describe('Commands (integration)', () => {
describe('commands.registerCommand', () => {
test('registers and unregisters a single command', async () => {
const { services, extensionHost } = await integrationTestContext()
const { services, extensionAPI } = await integrationTestContext()
// Register the command and call it.
const unsubscribe = extensionHost.commands.registerCommand('c', () => 'a')
await expect(extensionHost.commands.executeCommand('c')).resolves.toBe('a')
const unsubscribe = extensionAPI.commands.registerCommand('c', () => 'a')
await expect(extensionAPI.commands.executeCommand('c')).resolves.toBe('a')
await expect(services.commands.executeCommand({ command: 'c' })).resolves.toBe('a')
// Unregister the command and ensure it's removed.
unsubscribe.unsubscribe()
await extensionHost.internal.sync()
await expect(extensionHost.commands.executeCommand('c')).rejects.toMatchObject({
await extensionAPI.internal.sync()
await expect(extensionAPI.commands.executeCommand('c')).rejects.toMatchObject({
message: 'command not found: "c"',
})
expect(() => services.commands.executeCommand({ command: 'c' })).toThrow()
})
test('supports multiple commands', async () => {
const { services, extensionHost } = await integrationTestContext()
const { services, extensionAPI } = await integrationTestContext()
// Register 2 commands with different results.
extensionHost.commands.registerCommand('c1', () => 'a1')
extensionHost.commands.registerCommand('c2', () => 'a2')
await extensionHost.internal.sync()
extensionAPI.commands.registerCommand('c1', () => 'a1')
extensionAPI.commands.registerCommand('c2', () => 'a2')
await extensionAPI.internal.sync()
await expect(extensionHost.commands.executeCommand('c1')).resolves.toBe('a1')
await expect(extensionAPI.commands.executeCommand('c1')).resolves.toBe('a1')
await expect(services.commands.executeCommand({ command: 'c1' })).resolves.toBe('a1')
await expect(extensionHost.commands.executeCommand('c2')).resolves.toBe('a2')
await expect(extensionAPI.commands.executeCommand('c2')).resolves.toBe('a2')
await expect(services.commands.executeCommand({ command: 'c2' })).resolves.toBe('a2')
})
})

View File

@ -6,36 +6,38 @@ import { integrationTestContext } from './testHelpers'
describe('Configuration (integration)', () => {
test('is synchronously available', async () => {
const { extensionHost } = await integrationTestContext({ settings: of(EMPTY_SETTINGS_CASCADE) })
expect(() => extensionHost.configuration.subscribe(() => void 0)).not.toThrow()
expect(() => extensionHost.configuration.get()).not.toThrow()
const { extensionAPI } = await integrationTestContext({ settings: of(EMPTY_SETTINGS_CASCADE) })
expect(() => extensionAPI.configuration.subscribe(() => void 0)).not.toThrow()
expect(() => extensionAPI.configuration.get()).not.toThrow()
})
describe('Configuration#get', () => {
test('gets configuration', async () => {
const { extensionHost } = await integrationTestContext({ settings: of({ final: { a: 1 }, subjects: [] }) })
assertToJSON(extensionHost.configuration.get(), { a: 1 })
expect(extensionHost.configuration.get().value).toEqual({ a: 1 })
const { extensionAPI } = await integrationTestContext({
settings: of({ final: { a: 1 }, subjects: [] }),
})
assertToJSON(extensionAPI.configuration.get(), { a: 1 })
expect(extensionAPI.configuration.get().value).toEqual({ a: 1 })
})
})
describe('Configuration#update', () => {
test('updates configuration', async () => {
const calls: (SettingsEdit | string)[] = []
const { extensionHost } = await integrationTestContext({
const { extensionAPI } = await integrationTestContext({
settings: of({ final: { a: 1 }, subjects: [{ subject: {} as any, lastID: null, settings: null }] }),
updateSettings: async (_subject, edit) => {
calls.push(edit)
},
})
await extensionHost.configuration.get().update('a', 2)
await extensionHost.internal.sync()
await extensionAPI.configuration.get().update('a', 2)
await extensionAPI.internal.sync()
expect(calls).toEqual([{ path: ['a'], value: 2 }] as SettingsEdit[])
calls.length = 0 // clear
await extensionHost.configuration.get().update('a', 3)
await extensionHost.internal.sync()
await extensionAPI.configuration.get().update('a', 3)
await extensionAPI.internal.sync()
expect(calls).toEqual([{ path: ['a'], value: 3 }] as SettingsEdit[])
})
})
@ -43,14 +45,14 @@ describe('Configuration (integration)', () => {
describe('configuration.subscribe', () => {
test('subscribes to changes', async () => {
const mockSettings = new BehaviorSubject<SettingsCascadeOrError>(EMPTY_SETTINGS_CASCADE)
const { extensionHost } = await integrationTestContext({ settings: mockSettings })
const { extensionAPI } = await integrationTestContext({ settings: mockSettings })
let calls = 0
extensionHost.configuration.subscribe(() => calls++)
extensionAPI.configuration.subscribe(() => calls++)
expect(calls).toBe(1) // called initially
mockSettings.next(EMPTY_SETTINGS_CASCADE)
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(calls).toBe(2)
})
})

View File

@ -6,11 +6,11 @@ import { collectSubscribableValues, integrationTestContext } from './testHelpers
describe('Context (integration)', () => {
describe('internal.updateContext', () => {
test('updates context', async () => {
const { services, extensionHost } = await integrationTestContext()
const { services, extensionAPI } = await integrationTestContext()
const values = collectSubscribableValues(from(services.context.data).pipe(distinctUntilChanged()))
extensionHost.internal.updateContext({ a: 1 })
await extensionHost.internal.sync()
extensionAPI.internal.updateContext({ a: 1 })
await extensionAPI.internal.sync()
expect(values).toEqual([
{ 'clientApplication.isSourcegraph': true, 'clientApplication.extensionAPIVersion.major': 3 },
{ a: 1, 'clientApplication.isSourcegraph': true, 'clientApplication.extensionAPIVersion.major': 3 },

View File

@ -4,14 +4,14 @@ import { collectSubscribableValues, integrationTestContext } from './testHelpers
describe('Documents (integration)', () => {
describe('workspace.textDocuments', () => {
test('lists text documents', async () => {
const { extensionHost } = await integrationTestContext()
expect(extensionHost.workspace.textDocuments).toEqual([
const { extensionAPI } = await integrationTestContext()
expect(extensionAPI.workspace.textDocuments).toEqual([
{ uri: 'file:///f', languageId: 'l', text: 't' },
] as TextDocument[])
})
test('adds new text documents', async () => {
const { model, extensionHost } = await integrationTestContext()
const { model, extensionAPI } = await integrationTestContext()
model.next({
...model.value,
visibleViewComponents: [
@ -23,8 +23,8 @@ describe('Documents (integration)', () => {
},
],
})
await extensionHost.internal.sync()
expect(extensionHost.workspace.textDocuments).toEqual([
await extensionAPI.internal.sync()
expect(extensionAPI.workspace.textDocuments).toEqual([
{ uri: 'file:///f', languageId: 'l', text: 't' },
{ uri: 'file:///f2', languageId: 'l2', text: 't2' },
] as TextDocument[])
@ -33,9 +33,9 @@ describe('Documents (integration)', () => {
describe('workspace.openedTextDocuments', () => {
test('fires when a text document is opened', async () => {
const { model, extensionHost } = await integrationTestContext()
const { model, extensionAPI } = await integrationTestContext()
const values = collectSubscribableValues(extensionHost.workspace.openedTextDocuments)
const values = collectSubscribableValues(extensionAPI.workspace.openedTextDocuments)
expect(values).toEqual([] as TextDocument[])
model.next({
@ -49,7 +49,7 @@ describe('Documents (integration)', () => {
},
],
})
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(values).toEqual([{ uri: 'file:///f2', languageId: 'l2', text: 't2' }] as TextDocument[])
})

View File

@ -2,8 +2,8 @@ import { integrationTestContext } from './testHelpers'
describe('Internal (integration)', () => {
test('constant values', async () => {
const { extensionHost } = await integrationTestContext()
expect(extensionHost.internal.sourcegraphURL.toString()).toEqual('https://example.com')
expect(extensionHost.internal.clientApplication).toEqual('sourcegraph')
const { extensionAPI } = await integrationTestContext()
expect(extensionAPI.internal.sourcegraphURL.toString()).toEqual('https://example.com')
expect(extensionAPI.internal.clientApplication).toEqual('sourcegraph')
})
})

View File

@ -1,6 +1,6 @@
import { Location } from '@sourcegraph/extension-api-types'
import { Observable } from 'rxjs'
import { bufferCount, take } from 'rxjs/operators'
import { asyncScheduler, Observable, of } from 'rxjs'
import { observeOn, take, toArray } from 'rxjs/operators'
import * as sourcegraph from 'sourcegraph'
import { languages as sourcegraphLanguages } from 'sourcegraph'
import { Services } from '../client/services'
@ -9,86 +9,84 @@ import { URI } from '../extension/types/uri'
import { createBarrier, integrationTestContext } from './testHelpers'
describe('LanguageFeatures (integration)', () => {
testLocationProvider<sourcegraph.HoverProvider>(
'registerHoverProvider',
extensionHost => extensionHost.languages.registerHoverProvider,
label => ({
provideHover: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) => ({
contents: { value: label, kind: sourcegraph.MarkupKind.PlainText },
}),
testLocationProvider<sourcegraph.HoverProvider>({
name: 'registerHoverProvider',
registerProvider: extensionAPI => (s, p) => extensionAPI.languages.registerHoverProvider(s, p),
labeledProvider: label => ({
provideHover: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) =>
of({
contents: { value: label, kind: sourcegraph.MarkupKind.PlainText },
}).pipe(observeOn(asyncScheduler)),
}),
labels => ({
labeledProviderResults: labels => ({
contents: labels.map(label => ({ value: label, kind: sourcegraph.MarkupKind.PlainText })),
}),
run => ({ provideHover: run } as sourcegraph.HoverProvider),
services =>
providerWithImplementation: run => ({ provideHover: run } as sourcegraph.HoverProvider),
getResult: services =>
services.textDocumentHover.getHover({
textDocument: { uri: 'file:///f' },
position: { line: 1, character: 2 },
})
)
testLocationProvider<sourcegraph.DefinitionProvider>(
'registerDefinitionProvider',
extensionHost => extensionHost.languages.registerDefinitionProvider,
label => ({
provideDefinition: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) => [
{ uri: new URI(`file:///${label}`) },
],
}),
})
testLocationProvider<sourcegraph.DefinitionProvider>({
name: 'registerDefinitionProvider',
registerProvider: extensionAPI => extensionAPI.languages.registerDefinitionProvider,
labeledProvider: label => ({
provideDefinition: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) =>
of([{ uri: new URI(`file:///${label}`) }]).pipe(observeOn(asyncScheduler)),
}),
labeledDefinitionResults,
run => ({ provideDefinition: run } as sourcegraph.DefinitionProvider),
services =>
labeledProviderResults: labeledDefinitionResults,
providerWithImplementation: run => ({ provideDefinition: run } as sourcegraph.DefinitionProvider),
getResult: services =>
services.textDocumentDefinition.getLocations({
textDocument: { uri: 'file:///f' },
position: { line: 1, character: 2 },
})
)
}),
})
// tslint:disable deprecation The tests must remain until they are removed.
testLocationProvider(
'registerTypeDefinitionProvider',
extensionHost => extensionHost.languages.registerTypeDefinitionProvider,
label => ({
provideTypeDefinition: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) => [
{ uri: new URI(`file:///${label}`) },
],
testLocationProvider({
name: 'registerTypeDefinitionProvider',
registerProvider: extensionAPI => extensionAPI.languages.registerTypeDefinitionProvider,
labeledProvider: label => ({
provideTypeDefinition: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) =>
of([{ uri: new URI(`file:///${label}`) }]).pipe(observeOn(asyncScheduler)),
}),
labeledDefinitionResults,
run => ({ provideTypeDefinition: run } as sourcegraph.TypeDefinitionProvider),
services =>
labeledProviderResults: labeledDefinitionResults,
providerWithImplementation: run => ({ provideTypeDefinition: run } as sourcegraph.TypeDefinitionProvider),
getResult: services =>
services.textDocumentTypeDefinition.getLocations({
textDocument: { uri: 'file:///f' },
position: { line: 1, character: 2 },
})
)
testLocationProvider<sourcegraph.ImplementationProvider>(
'registerImplementationProvider',
extensionHost => extensionHost.languages.registerImplementationProvider,
label => ({
provideImplementation: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) => [
{ uri: new URI(`file:///${label}`) },
],
}),
})
testLocationProvider<sourcegraph.ImplementationProvider>({
name: 'registerImplementationProvider',
registerProvider: extensionAPI => extensionAPI.languages.registerImplementationProvider,
labeledProvider: label => ({
provideImplementation: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) =>
of([{ uri: new URI(`file:///${label}`) }]).pipe(observeOn(asyncScheduler)),
}),
labeledDefinitionResults,
run => ({ provideImplementation: run } as sourcegraph.ImplementationProvider),
services =>
labeledProviderResults: labeledDefinitionResults,
providerWithImplementation: run => ({ provideImplementation: run } as sourcegraph.ImplementationProvider),
getResult: services =>
services.textDocumentImplementation.getLocations({
textDocument: { uri: 'file:///f' },
position: { line: 1, character: 2 },
})
)
}),
})
// tslint:enable deprecation
testLocationProvider<sourcegraph.ReferenceProvider>(
'registerReferenceProvider',
extensionHost => extensionHost.languages.registerReferenceProvider,
label => ({
testLocationProvider<sourcegraph.ReferenceProvider>({
name: 'registerReferenceProvider',
registerProvider: extensionAPI => extensionAPI.languages.registerReferenceProvider,
labeledProvider: label => ({
provideReferences: (
doc: sourcegraph.TextDocument,
pos: sourcegraph.Position,
context: sourcegraph.ReferenceContext
) => [{ uri: new URI(`file:///${label}`) }],
) => of([{ uri: new URI(`file:///${label}`) }]).pipe(observeOn(asyncScheduler)),
}),
labels => labels.map(label => ({ uri: `file:///${label}`, range: undefined })),
run =>
labeledProviderResults: labels => labels.map(label => ({ uri: `file:///${label}`, range: undefined })),
providerWithImplementation: run =>
({
provideReferences: (
doc: sourcegraph.TextDocument,
@ -96,56 +94,62 @@ describe('LanguageFeatures (integration)', () => {
_context: sourcegraph.ReferenceContext
) => run(doc, pos),
} as sourcegraph.ReferenceProvider),
services =>
getResult: services =>
services.textDocumentReferences.getLocations({
textDocument: { uri: 'file:///f' },
position: { line: 1, character: 2 },
context: { includeDeclaration: true },
})
)
testLocationProvider<sourcegraph.LocationProvider>(
'registerLocationProvider',
extensionHost => (selector, provider) =>
extensionHost.languages.registerLocationProvider('x', selector, provider),
label => ({
provideLocations: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) => [
{ uri: new URI(`file:///${label}`) },
],
}),
})
testLocationProvider<sourcegraph.LocationProvider>({
name: 'registerLocationProvider',
registerProvider: extensionAPI => (selector, provider) =>
extensionAPI.languages.registerLocationProvider('x', selector, provider),
labeledProvider: label => ({
provideLocations: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) =>
of([{ uri: new URI(`file:///${label}`) }]).pipe(observeOn(asyncScheduler)),
}),
labels => labels.map(label => ({ uri: `file:///${label}`, range: undefined })),
run =>
labeledProviderResults: labels => labels.map(label => ({ uri: `file:///${label}`, range: undefined })),
providerWithImplementation: run =>
({
provideLocations: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) => run(doc, pos),
} as sourcegraph.LocationProvider),
services =>
getResult: services =>
services.textDocumentLocations.getLocations('x', {
textDocument: { uri: 'file:///f' },
position: { line: 1, character: 2 },
})
)
}),
})
})
/**
* Generates test cases for sourcegraph.languages.registerXyzProvider functions and their associated
* XyzProviders, for providers that return a list of locations.
*/
function testLocationProvider<P>(
name: keyof typeof sourcegraphLanguages,
function testLocationProvider<P>({
name,
registerProvider,
labeledProvider,
labeledProviderResults,
providerWithImplementation,
getResult,
}: {
name: keyof typeof sourcegraphLanguages
registerProvider: (
extensionHost: typeof sourcegraph
) => (selector: sourcegraph.DocumentSelector, provider: P) => sourcegraph.Unsubscribable,
labeledProvider: (label: string) => P,
labeledProviderResults: (labels: string[]) => any,
providerWithImpl: (run: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) => void) => P,
extensionAPI: typeof sourcegraph
) => (selector: sourcegraph.DocumentSelector, provider: P) => sourcegraph.Unsubscribable
labeledProvider: (label: string) => P
labeledProviderResults: (labels: string[]) => any
providerWithImplementation: (run: (doc: sourcegraph.TextDocument, pos: sourcegraph.Position) => void) => P
getResult: (services: Services) => Observable<any>
): void {
}): void {
describe(`languages.${name}`, () => {
test('registers and unregisters a single provider', async () => {
const { services, extensionHost } = await integrationTestContext()
const { services, extensionAPI } = await integrationTestContext()
// Register the provider and call it.
const unsubscribe = registerProvider(extensionHost)(['*'], labeledProvider('a'))
await extensionHost.internal.sync()
const subscription = registerProvider(extensionAPI)(['*'], labeledProvider('a'))
await extensionAPI.internal.sync()
expect(
await getResult(services)
.pipe(take(1))
@ -153,7 +157,7 @@ function testLocationProvider<P>(
).toEqual(labeledProviderResults(['a']))
// Unregister the provider and ensure it's removed.
unsubscribe.unsubscribe()
subscription.unsubscribe()
expect(
await getResult(services)
.pipe(take(1))
@ -162,17 +166,17 @@ function testLocationProvider<P>(
})
test('supplies params to the provideXyz method', async () => {
const { services, extensionHost } = await integrationTestContext()
const { services, extensionAPI } = await integrationTestContext()
const { wait, done } = createBarrier()
registerProvider(extensionHost)(
registerProvider(extensionAPI)(
['*'],
providerWithImpl((doc, pos) => {
providerWithImplementation((doc, pos) => {
assertToJSON(doc, { uri: 'file:///f', languageId: 'l', text: 't' })
assertToJSON(pos, { line: 1, character: 2 })
done()
})
)
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
await getResult(services)
.pipe(take(1))
.toPromise()
@ -180,19 +184,19 @@ function testLocationProvider<P>(
})
test('supports multiple providers', async () => {
const { services, extensionHost } = await integrationTestContext()
const { services, extensionAPI } = await integrationTestContext()
// Register 2 providers with different results.
registerProvider(extensionHost)(['*'], labeledProvider('a'))
registerProvider(extensionHost)(['*'], labeledProvider('b'))
await extensionHost.internal.sync()
registerProvider(extensionAPI)(['*'], labeledProvider('a'))
registerProvider(extensionAPI)(['*'], labeledProvider('b'))
await extensionAPI.internal.sync()
// Expect it to emit the first provider's result first (and not block on both providers being ready).
expect(
await getResult(services)
.pipe(
take(2),
bufferCount(2)
toArray()
)
.toPromise()
).toEqual([labeledProviderResults(['a']), labeledProviderResults(['a', 'b'])])

View File

@ -5,20 +5,20 @@ import { collectSubscribableValues, integrationTestContext } from './testHelpers
describe('Workspace roots (integration)', () => {
describe('workspace.roots', () => {
test('lists roots', async () => {
const { extensionHost } = await integrationTestContext()
expect(extensionHost.workspace.roots).toEqual([{ uri: new URI('file:///') }] as WorkspaceRoot[])
const { extensionAPI } = await integrationTestContext()
expect(extensionAPI.workspace.roots).toEqual([{ uri: new URI('file:///') }] as WorkspaceRoot[])
})
test('adds new text documents', async () => {
const { model, extensionHost } = await integrationTestContext()
const { model, extensionAPI } = await integrationTestContext()
model.next({
...model.value,
roots: [{ uri: 'file:///a' }, { uri: 'file:///b' }],
})
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(extensionHost.workspace.roots).toEqual([
expect(extensionAPI.workspace.roots).toEqual([
{ uri: new URI('file:///a') },
{ uri: new URI('file:///b') },
] as WorkspaceRoot[])
@ -27,16 +27,16 @@ describe('Workspace roots (integration)', () => {
describe('workspace.rootChanges', () => {
test('fires when a root is added or removed', async () => {
const { model, extensionHost } = await integrationTestContext()
const { model, extensionAPI } = await integrationTestContext()
const values = collectSubscribableValues(extensionHost.workspace.rootChanges)
const values = collectSubscribableValues(extensionAPI.workspace.rootChanges)
expect(values).toEqual([] as void[])
model.next({
...model.value,
roots: [{ uri: 'file:///a' }],
})
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
expect(values).toEqual([void 0])
})

View File

@ -3,20 +3,32 @@ import { integrationTestContext } from './testHelpers'
describe('search (integration)', () => {
test('registers a query transformer', async () => {
const { services, extensionHost } = await integrationTestContext()
const { services, extensionAPI } = await integrationTestContext()
// Register the provider and call it
const unsubscribe = extensionHost.search.registerQueryTransformer({ transformQuery: () => 'bar' })
await extensionHost.internal.sync()
extensionAPI.search.registerQueryTransformer({
transformQuery: () => 'bar',
})
await extensionAPI.internal.sync()
expect(
await services.queryTransformer
.transformQuery('foo')
.pipe(take(1))
.toPromise()
).toEqual('bar')
})
test('unregisters a query transformer', async () => {
const { services, extensionAPI } = await integrationTestContext()
// Register the provider and call it
const subscription = extensionAPI.search.registerQueryTransformer({
transformQuery: () => 'bar',
})
await extensionAPI.internal.sync()
// Unregister the provider and ensure it's removed.
unsubscribe.unsubscribe()
subscription.unsubscribe()
await extensionAPI.internal.sync()
expect(
await services.queryTransformer
.transformQuery('foo')
@ -26,12 +38,12 @@ describe('search (integration)', () => {
})
test('supports multiple query transformers', async () => {
const { services, extensionHost } = await integrationTestContext()
const { services, extensionAPI } = await integrationTestContext()
// Register the provider and call it
extensionHost.search.registerQueryTransformer({ transformQuery: (q: string) => `${q} bar` })
extensionHost.search.registerQueryTransformer({ transformQuery: (q: string) => `${q} qux` })
await extensionHost.internal.sync()
extensionAPI.search.registerQueryTransformer({ transformQuery: (q: string) => `${q} bar` })
extensionAPI.search.registerQueryTransformer({ transformQuery: (q: string) => `${q} qux` })
await extensionAPI.internal.sync()
expect(
await services.queryTransformer
.transformQuery('foo')

View File

@ -33,11 +33,11 @@ const withSelections = (...selections: { start: number; end: number }[]): ViewCo
describe('Selections (integration)', () => {
describe('editor.selectionsChanged', () => {
test('reflects changes to the current selections', async () => {
const { model, extensionHost } = await integrationTestContext(undefined, {
const { model, extensionAPI } = await integrationTestContext(undefined, {
roots: [],
visibleViewComponents: [],
})
const selectionChanges = from(extensionHost.app.activeWindowChanges).pipe(
const selectionChanges = from(extensionAPI.app.activeWindowChanges).pipe(
filter(isDefined),
switchMap(window => window.activeViewComponentChanges),
filter(isDefined),
@ -54,7 +54,7 @@ describe('Selections (integration)', () => {
...model.value,
visibleViewComponents: [withSelections(...selections)],
})
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
}
assertToJSON(
selectionValues.map(selections => selections.map(s => ({ start: s.start.line, end: s.end.line }))),

View File

@ -1,13 +1,13 @@
import { BehaviorSubject, NEVER, NextObserver, of, Subscribable, throwError } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import 'message-port-polyfill'
import { BehaviorSubject, NEVER, NextObserver, Subscribable, throwError } from 'rxjs'
import * as sourcegraph from 'sourcegraph'
import { PlatformContext } from '../../platform/context'
import { createExtensionHostClient, ExtensionHostClient } from '../client/client'
import { EndpointPair, PlatformContext } from '../../platform/context'
import { ExtensionHostClient } from '../client/client'
import { createExtensionHostClientConnection } from '../client/connection'
import { Model } from '../client/model'
import { Services } from '../client/services'
import { InitData, startExtensionHost } from '../extension/extensionHost'
import { createConnection } from '../protocol/jsonrpc2/connection'
import { createMessageTransports } from '../protocol/jsonrpc2/testHelpers'
const FIXTURE_MODEL: Model = {
roots: [{ uri: 'file:///' }],
@ -27,7 +27,7 @@ const FIXTURE_MODEL: Model = {
interface TestContext {
client: ExtensionHostClient
extensionHost: typeof sourcegraph
extensionAPI: typeof sourcegraph
}
interface Mocks
@ -66,34 +66,32 @@ export async function integrationTestContext(
> {
const mocks = partialMocks ? { ...NOOP_MOCKS, ...partialMocks } : NOOP_MOCKS
const [clientTransports, serverTransports] = createMessageTransports()
const clientAPIChannel = new MessageChannel()
const extensionHostAPIChannel = new MessageChannel()
const extensionHostEndpoints: EndpointPair = {
proxy: clientAPIChannel.port2,
expose: extensionHostAPIChannel.port2,
}
const clientEndpoints: EndpointPair = {
proxy: extensionHostAPIChannel.port1,
expose: clientAPIChannel.port1,
}
const extensionHost = startExtensionHost(serverTransports)
const extensionHost = startExtensionHost(extensionHostEndpoints)
const services = new Services(mocks)
const client = createExtensionHostClient(
services,
of(clientTransports).pipe(
switchMap(async clientTransports => {
const connection = createConnection(clientTransports)
connection.listen()
const initData: InitData = {
sourcegraphURL: 'https://example.com',
clientApplication: 'sourcegraph',
}
await connection.sendRequest('initialize', [initData])
return connection
})
)
)
const initData: InitData = {
sourcegraphURL: 'https://example.com',
clientApplication: 'sourcegraph',
}
const client = await createExtensionHostClientConnection(clientEndpoints, services, initData)
services.model.model.next(initModel)
await (await extensionHost.__testAPI).internal.sync()
const extensionAPI = await extensionHost.extensionAPI
return {
client,
extensionHost: await extensionHost.__testAPI,
extensionAPI,
services,
model: services.model.model,
}

View File

@ -7,12 +7,12 @@ import { collectSubscribableValues, integrationTestContext } from './testHelpers
describe('Windows (integration)', () => {
describe('app.activeWindow', () => {
test('returns the active window', async () => {
const { extensionHost } = await integrationTestContext()
const { extensionAPI } = await integrationTestContext()
const viewComponent: Pick<ViewComponent, 'type' | 'document'> = {
type: 'CodeEditor' as 'CodeEditor',
document: { uri: 'file:///f', languageId: 'l', text: 't' },
}
assertToJSON(extensionHost.app.activeWindow, {
assertToJSON(extensionAPI.app.activeWindow, {
visibleViewComponents: [viewComponent],
activeViewComponent: viewComponent,
} as Window)
@ -21,12 +21,12 @@ describe('Windows (integration)', () => {
describe('app.activeWindowChanged', () => {
test('reflects changes to the active window', async () => {
const { extensionHost, model } = await integrationTestContext(undefined, {
const { extensionAPI, model } = await integrationTestContext(undefined, {
roots: [],
visibleViewComponents: [],
})
await extensionHost.internal.sync()
const values = collectSubscribableValues(extensionHost.app.activeWindowChanges)
await extensionAPI.internal.sync()
const values = collectSubscribableValues(extensionAPI.app.activeWindowChanges)
model.next({
...model.value,
visibleViewComponents: [
@ -42,7 +42,7 @@ describe('Windows (integration)', () => {
...model.value,
visibleViewComponents: [],
})
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
model.next({
...model.value,
visibleViewComponents: [
@ -54,7 +54,7 @@ describe('Windows (integration)', () => {
},
],
})
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
assertToJSON(values.map(w => w && w.activeViewComponent && w.activeViewComponent.document.uri), [
null,
'foo',
@ -66,12 +66,12 @@ describe('Windows (integration)', () => {
describe('app.windows', () => {
test('lists windows', async () => {
const { extensionHost } = await integrationTestContext()
const { extensionAPI } = await integrationTestContext()
const viewComponent: Pick<ViewComponent, 'type' | 'document'> = {
type: 'CodeEditor' as 'CodeEditor',
document: { uri: 'file:///f', languageId: 'l', text: 't' },
}
assertToJSON(extensionHost.app.windows, [
assertToJSON(extensionAPI.app.windows, [
{
visibleViewComponents: [viewComponent],
activeViewComponent: viewComponent,
@ -80,7 +80,7 @@ describe('Windows (integration)', () => {
})
test('adds new text documents', async () => {
const { model, extensionHost } = await integrationTestContext()
const { model, extensionAPI } = await integrationTestContext()
model.next({
...model.value,
@ -93,13 +93,13 @@ describe('Windows (integration)', () => {
},
],
})
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
const viewComponent: Pick<ViewComponent, 'type' | 'document'> = {
type: 'CodeEditor' as 'CodeEditor',
document: { uri: 'file:///f2', languageId: 'l2', text: 't2' },
}
assertToJSON(extensionHost.app.windows, [
assertToJSON(extensionAPI.app.windows, [
{
visibleViewComponents: [viewComponent],
activeViewComponent: viewComponent,
@ -110,7 +110,7 @@ describe('Windows (integration)', () => {
describe('Window', () => {
test('Window#visibleViewComponent', async () => {
const { model, extensionHost } = await integrationTestContext()
const { model, extensionAPI } = await integrationTestContext()
model.next({
...model.value,
@ -128,9 +128,9 @@ describe('Windows (integration)', () => {
...(model.value.visibleViewComponents || []),
],
})
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
assertToJSON(extensionHost.app.windows[0].visibleViewComponents, [
assertToJSON(extensionAPI.app.windows[0].visibleViewComponents, [
{
type: 'CodeEditor' as 'CodeEditor',
document: { uri: 'file:///inactive', languageId: 'inactive', text: 'inactive' },
@ -143,7 +143,7 @@ describe('Windows (integration)', () => {
})
test('Window#activeViewComponent', async () => {
const { model, extensionHost } = await integrationTestContext()
const { model, extensionAPI } = await integrationTestContext()
model.next({
...model.value,
@ -161,39 +161,39 @@ describe('Windows (integration)', () => {
...(model.value.visibleViewComponents || []),
],
})
await extensionHost.internal.sync()
await extensionAPI.internal.sync()
assertToJSON(extensionHost.app.windows[0].activeViewComponent, {
assertToJSON(extensionAPI.app.windows[0].activeViewComponent, {
type: 'CodeEditor' as 'CodeEditor',
document: { uri: 'file:///f', languageId: 'l', text: 't' },
} as ViewComponent)
})
test('Window#showNotification', async () => {
const { extensionHost, services } = await integrationTestContext()
const { extensionAPI, services } = await integrationTestContext()
const values = collectSubscribableValues(services.notifications.showMessages)
extensionHost.app.activeWindow!.showNotification('a') // tslint:disable-line deprecation
await extensionHost.internal.sync()
extensionAPI.app.activeWindow!.showNotification('a') // tslint:disable-line deprecation
await extensionAPI.internal.sync()
expect(values).toEqual([{ message: 'a', type: MessageType.Info }] as typeof values)
})
test('Window#showMessage', async () => {
const { extensionHost, services } = await integrationTestContext()
const { extensionAPI, services } = await integrationTestContext()
services.notifications.showMessageRequests.subscribe(({ resolve }) => resolve(Promise.resolve(null)))
const values = collectSubscribableValues(
services.notifications.showMessageRequests.pipe(map(({ message, type }) => ({ message, type })))
)
expect(await extensionHost.app.activeWindow!.showMessage('a')).toBe(null)
expect(await extensionAPI.app.activeWindow!.showMessage('a')).toBe(undefined)
expect(values).toEqual([{ message: 'a', type: MessageType.Info }] as typeof values)
})
test('Window#showInputBox', async () => {
const { extensionHost, services } = await integrationTestContext()
const { extensionAPI, services } = await integrationTestContext()
services.notifications.showInputs.subscribe(({ resolve }) => resolve(Promise.resolve('c')))
const values = collectSubscribableValues(
services.notifications.showInputs.pipe(map(({ message, defaultValue }) => ({ message, defaultValue })))
)
expect(await extensionHost.app.activeWindow!.showInputBox({ prompt: 'a', value: 'b' })).toBe('c')
expect(await extensionAPI.app.activeWindow!.showInputBox({ prompt: 'a', value: 'b' })).toBe('c')
expect(values).toEqual([{ message: 'a', defaultValue: 'b' }] as typeof values)
})
})

View File

@ -1,581 +0,0 @@
import { AbortController } from 'abort-controller'
import { Observable, of } from 'rxjs'
import { bufferCount, delay } from 'rxjs/operators'
import { createBarrier } from '../../integration-test/testHelpers'
import { createConnection } from './connection'
import { ErrorCodes } from './messages'
import { createMessagePipe, createMessageTransports } from './testHelpers'
describe('Connection', () => {
// Polyfill
;(global as any).AbortController = AbortController
test('handle single request', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, (p1, _signal) => {
expect(p1).toEqual(['foo'])
return p1
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
await expect(client.sendRequest(method, ['foo'])).resolves.toEqual(['foo'])
})
test('handle single request with async result', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, (p1, _signal) => {
expect(p1).toEqual(['foo'])
return Promise.resolve(p1)
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
await expect(client.sendRequest(method, ['foo'])).resolves.toEqual(['foo'])
})
test('abort undispatched request', async () => {
const [serverTransports, clientTransports] = createMessageTransports()
const b1 = createBarrier()
const b2 = createBarrier()
const server = createConnection(serverTransports)
server.onRequest('block', async () => {
b2.done()
await b1.wait
})
server.onRequest('undispatched', () => {
throw new Error('handler should not be called')
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
client.sendRequest('block').catch(null)
await b2.wait
const abortController = new AbortController()
const result = client.sendRequest('undispatched', ['foo'], abortController.signal)
abortController.abort()
b1.done()
await expect(result).rejects.toHaveProperty('name', 'AbortError')
})
test('abort request currently being handled', async () => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest('m', (_params, signal) => {
if (!signal) {
throw new Error('!signal')
}
return new Promise<number>(resolve => {
signal.addEventListener('abort', () => resolve(123))
})
})
server.onRequest('ping', () => 'pong')
server.listen()
const client = createConnection(clientTransports)
client.listen()
const abortController = new AbortController()
const result = client.sendRequest('m', undefined, abortController.signal)
await expect(client.sendRequest('ping')).resolves.toBe('pong') // waits until the 'm' message starts to be handled
abortController.abort()
await expect(result).rejects.toHaveProperty('name', 'AbortError')
})
test('send request with single observable emission', async () => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest('m', (params: [number]) => of(params[0] + 1).pipe(delay(0)))
server.listen()
const client = createConnection(clientTransports)
client.listen()
await expect(client.sendRequest<number>('m', [1])).resolves.toBe(2)
})
test('observe request with single observable emission', async () => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest('m', (params: [number]) => of(params[0] + 1))
server.listen()
const client = createConnection(clientTransports)
client.listen()
const result = client.observeRequest<number>('m', [1])
await expect(result.toPromise()).resolves.toEqual(2)
})
test('observe request with multiple observable emissions', async () => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(
'm',
(params: number[]) =>
new Observable<number>(observer => {
for (const v of params) {
observer.next(v + 1)
}
observer.complete()
})
)
server.listen()
const client = createConnection(clientTransports)
client.listen()
await expect(
client
.observeRequest<number>('m', [1, 2, 3, 4])
.pipe(bufferCount(4))
.toPromise()
).resolves.toEqual([2, 3, 4, 5])
})
test('abort request before it is handled', async () => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
let handled = false
server.onRequest('m', () => (handled = true))
server.listen()
const client = createConnection(clientTransports)
client.listen()
// Connection processes messages asynchronously.
// When calling observeRequest() and unsubscribing from
// the returned observable in the same tick,
// the request handler is never called.
const subscription = client.observeRequest('m').subscribe()
subscription.unsubscribe()
expect(handled).toBe(false)
})
test('abort request with observable emission', async () => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
const handlerCalled = createBarrier()
const handlerUnsubscribed = createBarrier()
server.onRequest(
'm',
() =>
new Observable<void>(() => {
handlerCalled.done()
return handlerUnsubscribed.done
})
)
server.listen()
const client = createConnection(clientTransports)
client.listen()
const subscription = client.observeRequest('m').subscribe()
// Connection processes messages asynchronously.
// wait until the request handler gets subscribed to server-side
// to unsubscribe client-side.
await handlerCalled.wait
subscription.unsubscribe()
await handlerUnsubscribed.wait
})
test('handle multiple requests', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, (p1, _signal) => p1)
server.listen()
const client = createConnection(clientTransports)
client.listen()
const promises: Promise<string>[] = []
promises.push(client.sendRequest(method, ['foo']))
promises.push(client.sendRequest(method, ['bar']))
await expect(Promise.all(promises)).resolves.toEqual([['foo'], ['bar']])
})
test('unhandled request', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.listen()
const client = createConnection(clientTransports)
client.listen()
await expect(client.sendRequest(method, ['foo'])).rejects.toHaveProperty('code', ErrorCodes.MethodNotFound)
})
test('handler throws an Error', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, () => {
throw new Error('test')
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
const result = client.sendRequest(method, ['foo'])
await expect(result).rejects.toMatchObject({ code: ErrorCodes.InternalError, message: 'test' })
await expect(result).rejects.toHaveProperty('data')
await expect(result.catch(err => typeof err.data.stack)).resolves.toBe('string')
})
test('handler returns a rejected Promise with an Error', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, () => Promise.reject(new Error('test')))
server.listen()
const client = createConnection(clientTransports)
client.listen()
const result = client.sendRequest(method, ['foo'])
await expect(result).rejects.toMatchObject({ code: ErrorCodes.InternalError, message: 'test' })
await expect(result).rejects.toHaveProperty('data')
await expect(result.catch(err => typeof err.data.stack)).resolves.toBe('string')
})
test('receives undefined request params as null', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, params => {
expect(params).toBe(null)
return ''
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
await client.sendRequest(method)
})
test('receives undefined notification params as null', async () => {
const method = 'testNotification'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onNotification(method, params => {
expect(params).toBe(null)
return ''
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
client.sendNotification(method)
})
test('receives null as null', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, params => {
expect(params).toEqual([null])
return null
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
await expect(client.sendRequest(method, [null])).resolves.toBe(null)
})
test('receives 0 as 0', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, params => {
expect(params).toEqual([0])
return 0
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
await expect(client.sendRequest(method, [0])).resolves.toBe(0)
})
const testNotification = 'testNotification'
test('sends and receives notification', done => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onNotification(testNotification, params => {
expect(params).toEqual([{ value: true }])
done()
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
client.sendNotification(testNotification, [{ value: true }])
})
test('unhandled notification event', done => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onUnhandledNotification(message => {
expect(message.method).toBe(testNotification)
expect(message.params).toEqual([{ value: true }])
done()
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
client.sendNotification(testNotification, [{ value: true }])
})
test('unsubscribes client connection', async () => {
const method = 'test/handleSingleRequest'
const [serverTransports, clientTransports] = createMessageTransports()
const client = createConnection(clientTransports)
const server = createConnection(serverTransports)
server.onRequest(method, _param => {
client.unsubscribe()
return ''
})
server.listen()
client.listen()
await expect(client.sendRequest(method, [''])).rejects.toMatchObject({
message:
'The underlying JSON-RPC connection got unsubscribed while responding to this test/handleSingleRequest request.',
})
server.unsubscribe()
})
test('unsubscribed connection throws', () => {
const client = createConnection(createMessagePipe())
client.listen()
client.unsubscribe()
expect(() => client.sendNotification(testNotification)).toThrow()
})
test('two listen throw', () => {
const client = createConnection(createMessagePipe())
client.listen()
expect(() => client.listen()).toThrow()
})
test('notify on connection unsubscribe', done => {
const client = createConnection(createMessagePipe())
client.listen()
client.onUnsubscribe(() => {
done()
})
client.unsubscribe()
})
test('params in notifications', done => {
const method = 'test'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onNotification(method, params => {
expect(params).toEqual([10, 'vscode'])
done()
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
client.sendNotification(method, [10, 'vscode'])
})
test('params in request/response', async () => {
const method = 'add'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, (params: number[]) => {
expect(params).toEqual([10, 20, 30])
return params.reduce((sum, n) => sum + n, 0)
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
await expect(client.sendRequest(method, [10, 20, 30])).resolves.toBe(60)
})
test('params in request/response with signal', async () => {
const method = 'add'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(method, (params: number[], _signal) => {
expect(params).toEqual([10, 20, 30])
return params.reduce((sum, n) => sum + n, 0)
})
server.listen()
const client = createConnection(clientTransports)
const signal = new AbortController().signal
client.listen()
await expect(client.sendRequest(method, [10, 20, 30], signal)).resolves.toBe(60)
})
test('1 param as array in request', async () => {
const type = 'add'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(type, p1 => {
expect(Array.isArray(p1)).toBeTruthy()
expect(p1[0]).toBe(10)
expect(p1[1]).toBe(20)
expect(p1[2]).toBe(30)
return 60
})
server.listen()
const client = createConnection(clientTransports)
const signal = new AbortController().signal
client.listen()
await expect(client.sendRequest(type, [10, 20, 30], signal)).resolves.toBe(60)
})
test('1 param as array in notification', done => {
const type = 'add'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onNotification(type, params => {
expect(params).toEqual([10, 20, 30])
done()
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
client.sendNotification(type, [10, 20, 30])
})
test('untyped request/response', async () => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest('test', (params: number[], _signal) => {
expect(params).toEqual([10, 20, 30])
return params.reduce((sum, n) => sum + n, 0)
})
server.listen()
const client = createConnection(clientTransports)
const signal = new AbortController().signal
client.listen()
await expect(client.sendRequest('test', [10, 20, 30], signal)).resolves.toBe(60)
})
test('untyped notification', done => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onNotification('test', (params: number[]) => {
expect(params).toEqual([10, 20, 30])
done()
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
client.sendNotification('test', [10, 20, 30])
})
test('star request handler', async () => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest((method: string, params: number[], _signal) => {
expect(method).toBe('test')
expect(params).toEqual([10, 20, 30])
return params.reduce((sum, n) => sum + n, 0)
})
server.listen()
const client = createConnection(clientTransports)
const signal = new AbortController().signal
client.listen()
await expect(client.sendRequest('test', [10, 20, 30], signal)).resolves.toBe(60)
})
test('star notification handler', done => {
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onNotification((method: string, params: number[]) => {
expect(method).toBe('test')
expect(params).toEqual([10, 20, 30])
done()
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
client.sendNotification('test', [10, 20, 30])
})
test('abort signal is undefined', async () => {
const type = 'add'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(type, (params: number[], _signal) => {
expect(params).toEqual([10, 20, 30])
return params.reduce((sum, n) => sum + n, 0)
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
await expect(client.sendRequest(type, [10, 20, 30], undefined)).resolves.toBe(60)
})
test('null params in request', async () => {
const type = 'add'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onRequest(type, _signal => 123)
server.listen()
const client = createConnection(clientTransports)
client.listen()
;(client.sendRequest as any)(type, null).then((result: any) => expect(result).toBe(123))
})
test('null params in notifications', done => {
const type = 'test'
const [serverTransports, clientTransports] = createMessageTransports()
const server = createConnection(serverTransports)
server.onNotification(type, params => {
expect(params).toBe(null)
done()
})
server.listen()
const client = createConnection(clientTransports)
client.listen()
;(client.sendNotification as any)(type, null)
})
})

View File

@ -1,719 +0,0 @@
import { toPromise } from 'abortable-rx'
import { from, fromEvent, isObservable, Observable, Observer, Subject, Unsubscribable } from 'rxjs'
import { takeUntil } from 'rxjs/operators'
import { isPromise } from '../../util'
import { Emitter, Event } from './events'
import { LinkedMap } from './linkedMap'
import {
ErrorCodes,
isNotificationMessage,
isRequestMessage,
isResponseMessage,
Message,
NotificationMessage,
RequestID,
RequestMessage,
ResponseError,
ResponseMessage,
} from './messages'
import { noopTracer, Tracer } from './trace'
import { DataCallback, MessageReader, MessageWriter } from './transport'
// Copied from vscode-languageserver to avoid adding extraneous dependencies.
export interface Logger {
error(message: string): void
warn(message: string): void
info(message: string): void
log(message: string): void
}
const NullLogger: Logger = Object.freeze({
error: () => {
/* noop */
},
warn: () => {
/* noop */
},
info: () => {
/* noop */
},
log: () => {
/* noop */
},
})
export enum ConnectionErrors {
/**
* The connection is closed.
*/
Closed = 1,
/**
* The connection got unsubscribed (i.e., disposed).
*/
Unsubscribed = 2,
/**
* The connection is already in listening mode.
*/
AlreadyListening = 3,
}
export class ConnectionError extends Error {
public readonly code: ConnectionErrors
constructor(code: ConnectionErrors, message: string) {
super(message)
this.code = code
Object.setPrototypeOf(this, ConnectionError.prototype)
}
}
type MessageQueue = LinkedMap<string, Message>
type HandlerResult<R, E> =
| R
| ResponseError<E>
| Promise<R>
| Promise<ResponseError<E>>
| Promise<R | ResponseError<E>>
| Observable<R>
type StarRequestHandler = (method: string, params?: any, signal?: AbortSignal) => HandlerResult<any, any>
type GenericRequestHandler<R, E> = (params?: any, signal?: AbortSignal) => HandlerResult<R, E>
type StarNotificationHandler = (method: string, params?: any) => void
type GenericNotificationHandler = (params: any) => void
export interface Connection extends Unsubscribable {
sendRequest<R>(method: string, params?: any[], signal?: AbortSignal): Promise<R>
observeRequest<R>(method: string, params?: any[]): Observable<R>
onRequest<R, E>(method: string, handler: GenericRequestHandler<R, E>): void
onRequest(handler: StarRequestHandler): void
sendNotification(method: string, params?: any[]): void
onNotification(method: string, handler: GenericNotificationHandler): void
onNotification(handler: StarNotificationHandler): void
trace(tracer: Tracer | null): void
onError: Event<[Error, Message | undefined, number | undefined]>
onClose: Event<void>
onUnhandledNotification: Event<NotificationMessage>
listen(): void
onUnsubscribe: Event<void>
}
export interface MessageTransports {
reader: MessageReader
writer: MessageWriter
}
export function createConnection(transports: MessageTransports, logger?: Logger): Connection {
if (!logger) {
logger = NullLogger
}
return _createConnection(transports, logger)
}
interface ResponseObserver {
/** The request's method. */
method: string
/** Only set in Trace.Verbose mode. */
request?: RequestMessage
/** The timestamp when the request was received. */
timerStart: number
/** Whether the request was aborted by the client. */
complete: boolean
/** The observable containing the result value(s) and state. */
observer: Observer<any>
}
enum ConnectionState {
New = 1,
Listening = 2,
Closed = 3,
Unsubscribed = 4,
}
interface RequestHandlerElement {
type: string | undefined
handler: GenericRequestHandler<any, any>
}
interface NotificationHandlerElement {
type: string | undefined
handler: GenericNotificationHandler
}
const ABORT_REQUEST_METHOD = '$/abortRequest'
function _createConnection(transports: MessageTransports, logger: Logger): Connection {
let sequenceNumber = 0
let notificationSquenceNumber = 0
let unknownResponseSquenceNumber = 0
const version = '2.0'
let starRequestHandler: StarRequestHandler | undefined
const requestHandlers: { [name: string]: RequestHandlerElement | undefined } = Object.create(null)
let starNotificationHandler: StarNotificationHandler | undefined
const notificationHandlers: { [name: string]: NotificationHandlerElement | undefined } = Object.create(null)
let timer = false
let messageQueue: MessageQueue = new LinkedMap<string, Message>()
let responseObservables: { [name: string]: ResponseObserver } = Object.create(null)
let requestAbortControllers: { [id: string]: AbortController } = Object.create(null)
let tracer: Tracer = noopTracer
let state: ConnectionState = ConnectionState.New
const errorEmitter = new Emitter<[Error, Message | undefined, number | undefined]>()
const closeEmitter: Emitter<void> = new Emitter<void>()
const unhandledNotificationEmitter: Emitter<NotificationMessage> = new Emitter<NotificationMessage>()
const unsubscribeEmitter: Emitter<void> = new Emitter<void>()
function createRequestQueueKey(id: string | number): string {
return 'req-' + id.toString()
}
function createResponseQueueKey(id: string | number | null): string {
if (id === null) {
return 'res-unknown-' + (++unknownResponseSquenceNumber).toString()
} else {
return 'res-' + id.toString()
}
}
function createNotificationQueueKey(): string {
return 'not-' + (++notificationSquenceNumber).toString()
}
function addMessageToQueue(queue: MessageQueue, message: Message): void {
if (isRequestMessage(message)) {
queue.set(createRequestQueueKey(message.id), message)
} else if (isResponseMessage(message)) {
const key = createResponseQueueKey(message.id) + Math.random() // TODO(sqs)
queue.set(key, message)
} else {
queue.set(createNotificationQueueKey(), message)
}
}
function isListening(): boolean {
return state === ConnectionState.Listening
}
function isClosed(): boolean {
return state === ConnectionState.Closed
}
function isUnsubscribed(): boolean {
return state === ConnectionState.Unsubscribed
}
function closeHandler(): void {
if (state === ConnectionState.New || state === ConnectionState.Listening) {
state = ConnectionState.Closed
closeEmitter.fire(undefined)
}
// If the connection is unsubscribed don't sent close events.
}
function readErrorHandler(error: Error): void {
errorEmitter.fire([error, undefined, undefined])
}
function writeErrorHandler(data: [Error, Message | undefined, number | undefined]): void {
errorEmitter.fire(data)
}
transports.reader.onClose(closeHandler)
transports.reader.onError(readErrorHandler)
transports.writer.onClose(closeHandler)
transports.writer.onError(writeErrorHandler)
function triggerMessageQueue(): void {
if (timer || messageQueue.size === 0) {
return
}
timer = true
setImmediateCompat(() => {
timer = false
processMessageQueue()
})
}
function processMessageQueue(): void {
if (messageQueue.size === 0) {
return
}
const message = messageQueue.shift()!
try {
if (isRequestMessage(message)) {
handleRequest(message)
} else if (isNotificationMessage(message)) {
handleNotification(message)
} else if (isResponseMessage(message)) {
handleResponse(message)
} else {
handleInvalidMessage(message)
}
} finally {
triggerMessageQueue()
}
}
const callback: DataCallback = message => {
try {
// We have received an abort signal. Check if the message is still in the queue and abort it if allowed
// to do so.
if (isNotificationMessage(message) && message.method === ABORT_REQUEST_METHOD) {
const key = createRequestQueueKey(message.params[0])
const toAbort = messageQueue.get(key)
if (isRequestMessage(toAbort)) {
messageQueue.delete(key)
const response: ResponseMessage = {
jsonrpc: '2.0',
id: toAbort.id,
error: { code: ErrorCodes.RequestAborted, message: 'request aborted' },
}
tracer.responseAborted(response, toAbort, message)
transports.writer.write(response)
return
}
}
addMessageToQueue(messageQueue, message)
} finally {
triggerMessageQueue()
}
}
function handleRequest(requestMessage: RequestMessage): void {
if (isUnsubscribed()) {
// we return here silently since we fired an event when the
// connection got unsubscribed.
return
}
const startTime = Date.now()
function reply(resultOrError: any | ResponseError<any>, complete: boolean): void {
const message: ResponseMessage = {
jsonrpc: version,
id: requestMessage.id,
complete,
}
if (resultOrError instanceof ResponseError) {
message.error = (resultOrError as ResponseError<any>).toJSON()
} else {
message.result = resultOrError === undefined ? null : resultOrError
}
tracer.responseSent(message, requestMessage, startTime)
transports.writer.write(message)
}
function replyError(error: ResponseError<any>): void {
const message: ResponseMessage = {
jsonrpc: version,
id: requestMessage.id,
error: error.toJSON(),
complete: true,
}
tracer.responseSent(message, requestMessage, startTime)
transports.writer.write(message)
}
function replySuccess(result: any): void {
// The JSON RPC defines that a response must either have a result or an error
// So we can't treat undefined as a valid response result.
if (result === undefined) {
result = null
}
const message: ResponseMessage = {
jsonrpc: version,
id: requestMessage.id,
result,
complete: true,
}
tracer.responseSent(message, requestMessage, startTime)
transports.writer.write(message)
}
function replyComplete(): void {
const message: ResponseMessage = {
jsonrpc: version,
id: requestMessage.id,
complete: true,
}
tracer.responseSent(message, requestMessage, startTime)
transports.writer.write(message)
}
tracer.requestReceived(requestMessage)
const element = requestHandlers[requestMessage.method]
const requestHandler: GenericRequestHandler<any, any> | undefined = element && element.handler
if (requestHandler || starRequestHandler) {
const abortController = new AbortController()
const signalKey = String(requestMessage.id)
requestAbortControllers[signalKey] = abortController
try {
const params = requestMessage.params !== undefined ? requestMessage.params : null
const handlerResult = requestHandler
? requestHandler(params, abortController.signal)
: starRequestHandler!(requestMessage.method, params, abortController.signal)
if (!handlerResult) {
delete requestAbortControllers[signalKey]
replySuccess(handlerResult)
} else if (isPromise(handlerResult) || isObservable(handlerResult)) {
const onComplete = () => {
delete requestAbortControllers[signalKey]
}
from(handlerResult)
.pipe(takeUntil(fromEvent(abortController.signal, 'abort')))
.subscribe(
value => reply(value, false),
error => {
onComplete()
if (error instanceof ResponseError) {
replyError(error as ResponseError<any>)
} else if (error && typeof error.message === 'string') {
replyError(
new ResponseError<any>(ErrorCodes.InternalError, error.message, {
stack: error.stack,
})
)
} else {
replyError(
new ResponseError<void>(
ErrorCodes.InternalError,
`Request ${
requestMessage.method
} failed unexpectedly without providing any details.`
)
)
}
},
() => {
onComplete()
replyComplete()
}
)
} else {
delete requestAbortControllers[signalKey]
reply(handlerResult, true)
}
} catch (error) {
delete requestAbortControllers[signalKey]
if (error instanceof ResponseError) {
reply(error as ResponseError<any>, true)
} else if (error && typeof error.message === 'string') {
replyError(
new ResponseError<any>(ErrorCodes.InternalError, error.message, {
stack: error.stack,
})
)
} else {
replyError(
new ResponseError<void>(
ErrorCodes.InternalError,
`Request ${requestMessage.method} failed unexpectedly without providing any details.`
)
)
}
}
} else {
replyError(new ResponseError<void>(ErrorCodes.MethodNotFound, `Unhandled method ${requestMessage.method}`))
}
}
function handleResponse(responseMessage: ResponseMessage): void {
if (isUnsubscribed()) {
// See handle request.
return
}
if (responseMessage.id === null) {
if (responseMessage.error) {
logger.error(
`Received response message without id: Error is: \n${JSON.stringify(
responseMessage.error,
undefined,
4
)}`
)
} else {
logger.error(`Received response message without id. No further error information provided.`)
}
} else {
const key = String(responseMessage.id)
const responseObservable = responseObservables[key]
if (responseObservable) {
tracer.responseReceived(
responseMessage,
responseObservable.request || responseObservable.method,
responseObservable.timerStart
)
try {
if (responseMessage.error) {
const { code, message, data } = responseMessage.error
const err = new ResponseError(code, message, data)
if (data && data.stack) {
err.stack = data.stack
}
responseObservable.observer.error(err)
} else if (responseMessage.result !== undefined) {
responseObservable.observer.next(responseMessage.result)
}
if (responseMessage.complete) {
responseObservable.complete = true
responseObservable.observer.complete()
}
} catch (error) {
if (error.message) {
logger.error(
`Response handler '${responseObservable.method}' failed with message: ${error.message}`
)
} else {
logger.error(`Response handler '${responseObservable.method}' failed unexpectedly.`)
}
} finally {
if (responseMessage.complete) {
delete responseObservables[key]
}
}
} else {
tracer.unknownResponseReceived(responseMessage)
}
}
}
function handleNotification(message: NotificationMessage): void {
if (isUnsubscribed()) {
// See handle request.
return
}
let notificationHandler: GenericNotificationHandler | undefined
if (message.method === ABORT_REQUEST_METHOD) {
notificationHandler = (params: [RequestID]) => {
const id = params[0]
const abortController = requestAbortControllers[String(id)]
if (abortController) {
abortController.abort()
}
}
} else {
const element = notificationHandlers[message.method]
if (element) {
notificationHandler = element.handler
}
}
if (notificationHandler || starNotificationHandler) {
try {
tracer.notificationReceived(message)
notificationHandler
? notificationHandler(message.params)
: starNotificationHandler!(message.method, message.params)
} catch (error) {
if (error.message) {
logger.error(`Notification handler '${message.method}' failed with message: ${error.message}`)
} else {
logger.error(`Notification handler '${message.method}' failed unexpectedly.`)
}
}
} else {
unhandledNotificationEmitter.fire(message)
}
}
function handleInvalidMessage(message: Message): void {
if (!message) {
logger.error('Received empty message.')
return
}
logger.error(
`Received message which is neither a response nor a notification message:\n${JSON.stringify(
message,
null,
4
)}`
)
// Test whether we find an id to reject the promise
const responseMessage: ResponseMessage = message as ResponseMessage
if (typeof responseMessage.id === 'string' || typeof responseMessage.id === 'number') {
const key = String(responseMessage.id)
const responseHandler = responseObservables[key]
if (responseHandler) {
responseHandler.observer.error(
new Error('The received response has neither a result nor an error property.')
)
}
}
}
function throwIfClosedOrUnsubscribed(): void {
if (isClosed()) {
throw new ConnectionError(
ConnectionErrors.Closed,
'Extension host connection unexpectedly closed. Reload the page to resolve.'
)
}
if (isUnsubscribed()) {
throw new ConnectionError(ConnectionErrors.Unsubscribed, 'Connection is unsubscribed.')
}
}
function throwIfListening(): void {
if (isListening()) {
throw new ConnectionError(ConnectionErrors.AlreadyListening, 'Connection is already listening')
}
}
function throwIfNotListening(): void {
if (!isListening()) {
throw new Error('Call listen() first.')
}
}
const sendNotification = (method: string, params?: any[]): void => {
throwIfClosedOrUnsubscribed()
const notificationMessage: NotificationMessage = {
jsonrpc: version,
method,
params,
}
tracer.notificationSent(notificationMessage)
transports.writer.write(notificationMessage)
}
/**
* @returns a hot observable with the result of sending the request
*/
const requestHelper = <R>(method: string, params?: any[]): Observable<R> => {
const id = sequenceNumber++
const requestMessage: RequestMessage = {
jsonrpc: version,
id,
method,
params,
}
const subject = new Subject<R>()
const responseObserver: ResponseObserver = {
method,
request: requestMessage,
timerStart: Date.now(),
complete: false,
observer: subject,
}
tracer.requestSent(requestMessage)
try {
transports.writer.write(requestMessage)
responseObservables[String(id)] = responseObserver
} catch (e) {
responseObserver.observer.error(
new ResponseError<void>(ErrorCodes.MessageWriteError, e.message ? e.message : 'Unknown reason')
)
}
return new Observable(observer => {
subject.subscribe(observer).add(() => {
if (
!isUnsubscribed() &&
responseObserver &&
!responseObserver.complete &&
!responseObserver.observer.closed
) {
sendNotification(ABORT_REQUEST_METHOD, [id])
}
})
})
}
const connection: Connection = {
sendNotification,
onNotification: (type: string | StarNotificationHandler, handler?: GenericNotificationHandler): void => {
throwIfClosedOrUnsubscribed()
if (typeof type === 'function') {
starNotificationHandler = type
} else if (handler) {
notificationHandlers[type] = { type: undefined, handler }
}
},
sendRequest: <R>(method: string, params?: any[], signal?: AbortSignal): Promise<R> => {
throwIfClosedOrUnsubscribed()
throwIfNotListening()
return toPromise(requestHelper<R>(method, params), signal)
},
observeRequest: <R>(method: string, params?: any[]): Observable<R> => {
throwIfClosedOrUnsubscribed()
throwIfNotListening()
return requestHelper<R>(method, params)
},
onRequest: <R, E>(type: string | StarRequestHandler, handler?: GenericRequestHandler<R, E>): void => {
throwIfClosedOrUnsubscribed()
if (typeof type === 'function') {
starRequestHandler = type
} else if (handler) {
requestHandlers[type] = { type: undefined, handler }
}
},
trace: (_tracer: Tracer | null) => {
if (_tracer) {
tracer = _tracer
} else {
tracer = noopTracer
}
},
onError: errorEmitter.event,
onClose: closeEmitter.event,
onUnhandledNotification: unhandledNotificationEmitter.event,
onUnsubscribe: unsubscribeEmitter.event,
unsubscribe: () => {
if (isUnsubscribed()) {
return
}
state = ConnectionState.Unsubscribed
unsubscribeEmitter.fire(undefined)
for (const key of Object.keys(responseObservables)) {
responseObservables[key].observer.error(
new ConnectionError(
ConnectionErrors.Unsubscribed,
`The underlying JSON-RPC connection got unsubscribed while responding to this ${
responseObservables[key].method
} request.`
)
)
}
responseObservables = Object.create(null)
requestAbortControllers = Object.create(null)
messageQueue = new LinkedMap<string, Message>()
transports.writer.unsubscribe()
transports.reader.unsubscribe()
},
listen: () => {
throwIfClosedOrUnsubscribed()
throwIfListening()
state = ConnectionState.Listening
transports.reader.listen(callback)
},
}
return connection
}
/** Support browser and node environments without needing a transpiler. */
function setImmediateCompat(f: () => void): void {
if (typeof setImmediate !== 'undefined') {
setImmediate(f)
return
}
setTimeout(f, 0)
}

View File

@ -1,137 +0,0 @@
import { Unsubscribable } from 'rxjs'
/**
* Represents a typed event.
*/
export type Event<T> = (listener: (e: T) => any, thisArgs?: any) => Unsubscribable
export namespace Event {
const _unsubscribable = {
unsubscribe(): void {
/* noop */
},
}
export const None: Event<any> = () => _unsubscribable
}
class CallbackList {
private _callbacks: ((...args: any[]) => any)[] | undefined
private _contexts: any[] | undefined
public add(callback: (...args: any[]) => any, context: any = null, bucket?: Unsubscribable[]): void {
if (!this._callbacks) {
this._callbacks = []
this._contexts = []
}
this._callbacks.push(callback)
this._contexts!.push(context)
if (Array.isArray(bucket)) {
bucket.push({ unsubscribe: () => this.remove(callback, context) })
}
}
public remove(callback: (...args: any[]) => any, context: any = null): void {
if (!this._callbacks) {
return
}
let foundCallbackWithDifferentContext = false
for (let i = 0, len = this._callbacks.length; i < len; i++) {
if (this._callbacks[i] === callback) {
if (this._contexts![i] === context) {
// callback & context match => remove it
this._callbacks.splice(i, 1)
this._contexts!.splice(i, 1)
return
} else {
foundCallbackWithDifferentContext = true
}
}
}
if (foundCallbackWithDifferentContext) {
throw new Error('When adding a listener with a context, you should remove it with the same context')
}
}
public invoke(...args: any[]): any[] {
if (!this._callbacks) {
return []
}
const ret: any[] = []
const callbacks = this._callbacks.slice(0)
const contexts = this._contexts!.slice(0)
for (let i = 0; i < callbacks.length; i++) {
try {
ret.push(callbacks[i].apply(contexts[i], args))
} catch (e) {
console.error(e)
}
}
return ret
}
public isEmpty(): boolean {
return !this._callbacks || this._callbacks.length === 0
}
public unsubscribe(): void {
this._callbacks = undefined
this._contexts = undefined
}
}
export class Emitter<T> {
private static _noop = () => void 0
private _event?: Event<T>
private _callbacks: CallbackList | undefined
/**
* For the public to allow to subscribe
* to events from this Emitter
*/
public get event(): Event<T> {
if (!this._event) {
this._event = (listener: (e: T) => any, thisArgs?: any, Unsubscribables?: Unsubscribable[]) => {
if (!this._callbacks) {
this._callbacks = new CallbackList()
}
this._callbacks.add(listener, thisArgs)
let result: Unsubscribable
result = {
unsubscribe: () => {
this._callbacks!.remove(listener, thisArgs)
result.unsubscribe = Emitter._noop
},
}
if (Array.isArray(Unsubscribables)) {
Unsubscribables.push(result)
}
return result
}
}
return this._event
}
/**
* To be kept private to fire an event to
* subscribers
*/
public fire(event: T): any {
if (this._callbacks) {
this._callbacks.invoke.call(this._callbacks, event)
}
}
public unsubscribe(): void {
if (this._callbacks) {
this._callbacks.unsubscribe()
this._callbacks = undefined
}
}
}

View File

@ -1,65 +0,0 @@
import { LinkedMap, Touch } from './linkedMap'
describe('LinkedMap', () => {
test('simple', () => {
const map = new LinkedMap<string, string>()
map.set('ak', 'av')
map.set('bk', 'bv')
expect(Array.from(map.keys())).toEqual(['ak', 'bk'])
expect(Array.from(map.values())).toEqual(['av', 'bv'])
})
test('touch first', () => {
const map = new LinkedMap<string, string>()
map.set('ak', 'av')
map.set('ak', 'av', Touch.First)
expect(Array.from(map.keys())).toEqual(['ak'])
expect(Array.from(map.values())).toEqual(['av'])
})
test('touch last', () => {
const map = new LinkedMap<string, string>()
map.set('ak', 'av')
map.set('ak', 'av', Touch.Last)
expect(Array.from(map.keys())).toEqual(['ak'])
expect(Array.from(map.values())).toEqual(['av'])
})
test('touch first 2', () => {
const map = new LinkedMap<string, string>()
map.set('ak', 'av')
map.set('bk', 'bv')
map.set('bk', 'bv', Touch.First)
expect(Array.from(map.keys())).toEqual(['bk', 'ak'])
expect(Array.from(map.values())).toEqual(['bv', 'av'])
})
test('touch last 2', () => {
const map = new LinkedMap<string, string>()
map.set('ak', 'av')
map.set('bk', 'bv')
map.set('ak', 'av', Touch.Last)
expect(Array.from(map.keys())).toEqual(['bk', 'ak'])
expect(Array.from(map.values())).toEqual(['bv', 'av'])
})
test('touch first from middle', () => {
const map = new LinkedMap<string, string>()
map.set('ak', 'av')
map.set('bk', 'bv')
map.set('ck', 'cv')
map.set('bk', 'bv', Touch.First)
expect(Array.from(map.keys())).toEqual(['bk', 'ak', 'ck'])
expect(Array.from(map.values())).toEqual(['bv', 'av', 'cv'])
})
test('touch last from middle', () => {
const map = new LinkedMap<string, string>()
map.set('ak', 'av')
map.set('bk', 'bv')
map.set('ck', 'cv')
map.set('bk', 'bv', Touch.Last)
expect(Array.from(map.keys())).toEqual(['ak', 'ck', 'bk'])
expect(Array.from(map.values())).toEqual(['av', 'cv', 'bv'])
})
})

View File

@ -1,272 +0,0 @@
interface Item<K, V> {
previous: Item<K, V> | undefined
next: Item<K, V> | undefined
key: K
value: V
}
export namespace Touch {
export const None: 0 = 0
export const First: 1 = 1
export const Last: 2 = 2
}
export type Touch = 0 | 1 | 2
export class LinkedMap<K, V> {
private _map: Map<K, Item<K, V>>
private _head: Item<K, V> | undefined
private _tail: Item<K, V> | undefined
private _size: number
constructor() {
this._map = new Map<K, Item<K, V>>()
this._head = undefined
this._tail = undefined
this._size = 0
}
public clear(): void {
this._map.clear()
this._head = undefined
this._tail = undefined
this._size = 0
}
public isEmpty(): boolean {
return !this._head && !this._tail
}
public get size(): number {
return this._size
}
public has(key: K): boolean {
return this._map.has(key)
}
public get(key: K): V | undefined {
const item = this._map.get(key)
if (!item) {
return undefined
}
return item.value
}
public set(key: K, value: V, touch: Touch = Touch.None): void {
let item = this._map.get(key)
if (item) {
item.value = value
if (touch !== Touch.None) {
this.touch(item, touch)
}
} else {
item = { key, value, next: undefined, previous: undefined }
switch (touch) {
case Touch.None:
this.addItemLast(item)
break
case Touch.First:
this.addItemFirst(item)
break
case Touch.Last:
this.addItemLast(item)
break
default:
this.addItemLast(item)
break
}
this._map.set(key, item)
this._size++
}
}
public delete(key: K): boolean {
const item = this._map.get(key)
if (!item) {
return false
}
this._map.delete(key)
this.removeItem(item)
this._size--
return true
}
public shift(): V | undefined {
if (!this._head && !this._tail) {
return undefined
}
if (!this._head || !this._tail) {
throw new Error('Invalid list')
}
const item = this._head
this._map.delete(item.key)
this.removeItem(item)
this._size--
return item.value
}
public forEach(callbackfn: (value: V, key: K, map: LinkedMap<K, V>) => void, thisArg?: any): void {
let current = this._head
while (current) {
if (thisArg) {
callbackfn.bind(thisArg)(current.value, current.key, this)
} else {
callbackfn(current.value, current.key, this)
}
current = current.next
}
}
public forEachReverse(callbackfn: (value: V, key: K, map: LinkedMap<K, V>) => void, thisArg?: any): void {
let current = this._tail
while (current) {
if (thisArg) {
callbackfn.bind(thisArg)(current.value, current.key, this)
} else {
callbackfn(current.value, current.key, this)
}
current = current.previous
}
}
public keys(): IterableIterator<K> {
let current = this._head
const iterator: IterableIterator<K> = {
[Symbol.iterator](): IterableIterator<K> {
return iterator
},
next(): IteratorResult<K> {
if (current) {
const result = { value: current.key, done: false }
current = current.next
return result
}
return { done: true } as IteratorResult<K>
},
}
return iterator
}
public values(): IterableIterator<V> {
let current = this._head
const iterator: IterableIterator<V> = {
[Symbol.iterator](): IterableIterator<V> {
return iterator
},
next(): IteratorResult<V> {
if (current) {
const result = { value: current.value, done: false }
current = current.next
return result
}
return { done: true } as IteratorResult<V>
},
}
return iterator
}
private addItemFirst(item: Item<K, V>): void {
// First time Insert
if (!this._head && !this._tail) {
this._tail = item
} else if (!this._head) {
throw new Error('Invalid list')
} else {
item.next = this._head
this._head.previous = item
}
this._head = item
}
private addItemLast(item: Item<K, V>): void {
// First time Insert
if (!this._head && !this._tail) {
this._head = item
} else if (!this._tail) {
throw new Error('Invalid list')
} else {
item.previous = this._tail
this._tail.next = item
}
this._tail = item
}
private removeItem(item: Item<K, V>): void {
if (item === this._head && item === this._tail) {
this._head = undefined
this._tail = undefined
} else if (item === this._head) {
this._head = item.next
} else if (item === this._tail) {
this._tail = item.previous
} else {
const next = item.next
const previous = item.previous
if (!next || !previous) {
throw new Error('Invalid list')
}
next.previous = previous
previous.next = next
}
}
private touch(item: Item<K, V>, touch: Touch): void {
if (!this._head || !this._tail) {
throw new Error('Invalid list')
}
if (touch !== Touch.First && touch !== Touch.Last) {
return
}
if (touch === Touch.First) {
if (item === this._head) {
return
}
const next = item.next
const previous = item.previous
// Unlink the item
if (item === this._tail) {
// previous must be defined since item was not head but is tail
// So there are more than on item in the map
previous!.next = undefined
this._tail = previous
} else {
// Both next and previous are not undefined since item was neither head nor tail.
next!.previous = previous
previous!.next = next
}
// Insert the node at head
item.previous = undefined
item.next = this._head
this._head.previous = item
this._head = item
} else if (touch === Touch.Last) {
if (item === this._tail) {
return
}
const next = item.next
const previous = item.previous
// Unlink the item.
if (item === this._head) {
// next must be defined since item was not tail but is head
// So there are more than on item in the map
next!.previous = undefined
this._head = next
} else {
// Both next and previous are not undefined since item was neither head nor tail.
next!.previous = previous
previous!.next = next
}
item.next = undefined
item.previous = this._tail
this._tail.next = item
this._tail = item
}
}
}

View File

@ -1,167 +0,0 @@
/**
* A language server message
*/
export interface Message {
jsonrpc: string
}
/**
* The request ID.
*/
export type RequestID = number | string
/**
* Request message
*/
export interface RequestMessage extends Message {
/**
* The request ID.
*/
id: RequestID
/**
* The method to be invoked.
*/
method: string
/**
* The method's params.
*/
params?: any
}
/**
* Predefined error codes.
*/
export namespace ErrorCodes {
// Defined by JSON-RPC 2.0.
export const ParseError = -32700
export const InvalidRequest = -32600
export const MethodNotFound = -32601
export const InvalidParams = -32602
export const InternalError = -32603
export const serverErrorStart = -32099
export const serverErrorEnd = -32000
export const ServerNotInitialized = -32002
export const UnknownErrorCode = -32001
// Defined by this library.
export const MessageWriteError = 1
export const MessageReadError = 2
export const RequestAborted = -32800
}
interface ResponseErrorLiteral<D> {
/**
* A number indicating the error type that occured.
*/
code: number
/**
* A string providing a short decription of the error.
*/
message: string
/**
* A Primitive or Structured value that contains additional
* information about the error. Can be omitted.
*/
data?: D
}
/**
* An error object return in a response in case a request
* has failed.
*/
export class ResponseError<D> extends Error {
public readonly code: number
public readonly data: D | undefined
constructor(code: number, message: string, data?: D) {
super(message)
this.code = typeof code === 'number' ? code : ErrorCodes.UnknownErrorCode
this.data = data
Object.setPrototypeOf(this, ResponseError.prototype)
}
public toJSON(): ResponseErrorLiteral<D> {
return {
code: this.code,
message: this.message,
data: this.data,
}
}
}
/**
* A response message.
*/
export interface ResponseMessage extends Message {
/**
* The request id.
*/
id: number | string | null
/**
* The result of a request. This can be omitted in
* the case of an error.
*/
result?: any
/**
* The error object in case a request fails.
*/
error?: ResponseErrorLiteral<any>
/**
* Whether the request is completed (no more values will be emitted).
*/
complete?: boolean
}
/**
* Notification Message
*/
export interface NotificationMessage extends Message {
/**
* The method to be invoked.
*/
method: string
/**
* The notification's params.
*/
params?: any
}
/**
* Tests if the given message is a request message
*/
export function isRequestMessage(message: Message | undefined): message is RequestMessage {
const candidate = message as RequestMessage
return (
candidate &&
typeof candidate.method === 'string' &&
(typeof candidate.id === 'string' || typeof candidate.id === 'number')
)
}
/**
* Tests if the given message is a notification message
*/
export function isNotificationMessage(message: Message | undefined): message is NotificationMessage {
const candidate = message as NotificationMessage
return candidate && typeof candidate.method === 'string' && (message as any).id === void 0
}
/**
* Tests if the given message is a response message
*/
export function isResponseMessage(message: Message | undefined): message is ResponseMessage {
const candidate = message as ResponseMessage
return (
candidate &&
(candidate.result !== void 0 || !!candidate.error || !!candidate.complete) &&
(typeof candidate.id === 'string' || typeof candidate.id === 'number' || candidate.id === null)
)
}

View File

@ -1,33 +0,0 @@
import { MessageTransports } from './connection'
import { Message } from './messages'
import { AbstractMessageReader, AbstractMessageWriter, DataCallback, MessageReader, MessageWriter } from './transport'
/**
* Creates a pair of message transports that are connected to each other. One can be used as the server and the
* other as the client.
*/
export function createMessageTransports(): [MessageTransports, MessageTransports] {
const { reader: reader1, writer: writer1 } = createMessagePipe()
const { reader: reader2, writer: writer2 } = createMessagePipe()
return [{ reader: reader1, writer: writer2 }, { reader: reader2, writer: writer1 }]
}
/** Creates a single set of transports that are connected to each other. */
export function createMessagePipe(): MessageTransports {
let readerCallback: DataCallback | undefined
const reader: MessageReader = new class extends AbstractMessageReader implements MessageReader {
public listen(callback: DataCallback): void {
readerCallback = callback
}
}()
const writer: MessageWriter = new class extends AbstractMessageWriter implements MessageWriter {
public write(msg: Message): void {
if (readerCallback) {
readerCallback(msg)
} else {
throw new Error('reader has no listener')
}
}
}()
return { reader, writer }
}

View File

@ -1,124 +0,0 @@
import { NotificationMessage, RequestMessage, ResponseMessage } from './messages'
// Copied from vscode-jsonrpc to avoid adding extraneous dependencies.
/** Records messages sent and received on a JSON-RPC 2.0 connection. */
export interface Tracer {
log(message: string, details?: string): void
requestSent(message: RequestMessage): void
requestReceived(message: RequestMessage): void
notificationSent(message: NotificationMessage): void
notificationReceived(message: NotificationMessage): void
responseSent(message: ResponseMessage, request: RequestMessage, startTime: number): void
responseAborted(message: ResponseMessage, request: RequestMessage, abortMessage: NotificationMessage): void
responseReceived(message: ResponseMessage, request: RequestMessage | string, startTime: number): void
unknownResponseReceived(message: ResponseMessage): void
}
/** A tracer that implements the Tracer interface with noop methods. */
export const noopTracer: Tracer = {
log: () => void 0,
requestSent: () => void 0,
requestReceived: () => void 0,
notificationSent: () => void 0,
notificationReceived: () => void 0,
responseSent: () => void 0,
responseAborted: () => void 0,
responseReceived: () => void 0,
unknownResponseReceived: () => void 0,
}
/** A tracer that implements the Tracer interface with console API calls, intended for a web browser. */
export class BrowserConsoleTracer implements Tracer {
public constructor(private name: string) {}
private prefix(level: 'info' | 'error', label: string, title: string): string[] {
let color: string
let backgroundColor: string
if (level === 'info') {
color = '#000'
backgroundColor = '#eee'
} else {
color = 'white'
backgroundColor = 'red'
}
return [
'%c%s%c %s%c%s%c',
`font-weight:bold;background-color:#d8f7ff;color:black`,
this.name,
'',
label,
`background-color:${backgroundColor};color:${color};font-weight:bold`,
title,
'',
]
}
public log(message: string, details?: string): void {
if (details) {
;(console.groupCollapsed as any)(...this.prefix('info', 'log', ''), message)
console.log(details)
console.groupEnd()
} else {
console.log(...this.prefix('info', 'log', ''), message)
}
}
public requestSent(message: RequestMessage): void {
console.log(...this.prefix('info', `◀◀ sent request #${message.id}: `, message.method), message.params)
}
public requestReceived(message: RequestMessage): void {
console.log(...this.prefix('info', `▶▶ recv request #${message.id}: `, message.method), message.params)
}
public notificationSent(message: NotificationMessage): void {
console.log(...this.prefix('info', `◀◀ sent notif: `, message.method), message.params)
}
public notificationReceived(message: NotificationMessage): void {
console.log(...this.prefix('info', `▶▶ recv notif: `, message.method), message.params)
}
public responseSent(message: ResponseMessage, request: RequestMessage, startTime: number): void {
const prefix = this.prefix(
message.error ? 'error' : 'info',
`◀▶ sent response #${message.id}: `,
typeof request === 'string' ? request : request.method
)
;(console.groupCollapsed as any)(...prefix)
console.log('Response:', message)
console.log('Request:', request)
console.log('Duration: %d msec', Date.now() - startTime)
console.groupEnd()
}
public responseAborted(
_message: ResponseMessage,
request: RequestMessage,
_abortMessage: NotificationMessage
): void {
console.log(...this.prefix('info', '× abort: ', request.method))
}
public responseReceived(message: ResponseMessage, request: RequestMessage | string, startTime: number): void {
const prefix = this.prefix(
message.error ? 'error' : 'info',
`◀▶ recv response #${message.id}: `,
typeof request === 'string' ? request : request.method
)
if (typeof request === 'string') {
console.log(...prefix, message.error || message.result)
} else {
;(console.groupCollapsed as any)(...prefix)
console.log('Response:', message)
console.log('Request:', request)
console.log('Duration: %d msec', Date.now() - startTime)
console.groupEnd()
}
}
public unknownResponseReceived(message: ResponseMessage): void {
console.log(...this.prefix('error', 'UNKNOWN', ''), message)
}
}

View File

@ -1,95 +0,0 @@
import { Emitter, Event } from './events'
import { Message } from './messages'
// Copied from vscode-jsonrpc to avoid adding extraneous dependencies.
export type DataCallback = (data: Message) => void
export interface MessageReader {
readonly onError: Event<Error>
readonly onClose: Event<void>
listen(callback: DataCallback): void
unsubscribe(): void
}
export abstract class AbstractMessageReader {
private errorEmitter: Emitter<Error>
private closeEmitter: Emitter<void>
constructor() {
this.errorEmitter = new Emitter<Error>()
this.closeEmitter = new Emitter<void>()
}
public unsubscribe(): void {
this.errorEmitter.unsubscribe()
this.closeEmitter.unsubscribe()
}
public get onError(): Event<Error> {
return this.errorEmitter.event
}
protected fireError(error: any): void {
this.errorEmitter.fire(this.asError(error))
}
public get onClose(): Event<void> {
return this.closeEmitter.event
}
protected fireClose(): void {
this.closeEmitter.fire(undefined)
}
private asError(error: any): Error {
if (error instanceof Error) {
return error
}
return new Error(
`Reader received error. Reason: ${typeof error.message === 'string' ? error.message : 'unknown'}`
)
}
}
export interface MessageWriter {
readonly onError: Event<[Error, Message | undefined, number | undefined]>
readonly onClose: Event<void>
write(msg: Message): void
unsubscribe(): void
}
export abstract class AbstractMessageWriter {
private errorEmitter = new Emitter<[Error, Message | undefined, number | undefined]>()
private closeEmitter = new Emitter<void>()
public unsubscribe(): void {
this.errorEmitter.unsubscribe()
this.closeEmitter.unsubscribe()
}
public get onError(): Event<[Error, Message | undefined, number | undefined]> {
return this.errorEmitter.event
}
protected fireError(error: any, message?: Message, count?: number): void {
this.errorEmitter.fire([this.asError(error), message, count])
}
public get onClose(): Event<void> {
return this.closeEmitter.event
}
protected fireClose(): void {
this.closeEmitter.fire(undefined)
}
private asError(error: any): Error {
if (error instanceof Error) {
return error
}
return new Error(
`Writer received error. Reason: ${typeof error.message === 'string' ? error.message : 'unknown'}`
)
}
}

View File

@ -1,130 +0,0 @@
import { MessageTransports } from '../connection'
import { Message } from '../messages'
import { AbstractMessageReader, AbstractMessageWriter, DataCallback, MessageReader, MessageWriter } from '../transport'
// TODO: use transferable objects in postMessage for perf
// Copied subset of Worker from the TypeScript "dom"/"webworker" core libraries to avoid needing to add those libs
// to tsconfig.json.
interface MessageEvent {
data: any
}
interface WorkerEventMap {
error: any
message: MessageEvent
}
interface Worker {
postMessage(message: any): void
addEventListener<K extends keyof WorkerEventMap>(
type: K,
listener: (this: Worker, ev: WorkerEventMap[K]) => any
): void
close?(): void
terminate?(): void
}
class WebWorkerMessageReader extends AbstractMessageReader implements MessageReader {
private pending: Message[] = []
private callback: DataCallback | null = null
constructor(private worker: Worker) {
super()
worker.addEventListener('message', (e: MessageEvent) => {
try {
this.processMessage(e)
} catch (err) {
this.fireError(err)
}
})
worker.addEventListener('error', (err: ErrorEvent) => {
this.fireError(err)
if (err.cancelable) {
err.preventDefault()
} else {
terminateWorker(worker)
this.fireClose()
}
})
}
private processMessage(e: MessageEvent): void {
const message: Message = e.data
if (this.callback) {
this.callback(message)
} else {
this.pending.push(message)
}
}
public listen(callback: DataCallback): void {
if (this.callback) {
throw new Error('callback is already set')
}
this.callback = callback
while (this.pending.length !== 0) {
callback(this.pending.pop()!)
}
}
public unsubscribe(): void {
super.unsubscribe()
this.callback = null
terminateWorker(this.worker)
}
}
class WebWorkerMessageWriter extends AbstractMessageWriter implements MessageWriter {
private errorCount = 0
constructor(private worker: Worker) {
super()
}
public write(message: Message): void {
try {
this.worker.postMessage(message)
} catch (error) {
this.fireError(error, message, ++this.errorCount)
}
}
public unsubscribe(): void {
super.unsubscribe()
terminateWorker(this.worker)
}
}
function terminateWorker(worker: Worker): void {
if (worker.terminate) {
worker.terminate() // in window (worker parent) scope
} else if (worker.close) {
worker.close() // in worker scope
}
}
/**
* Creates JSON-RPC2 message transports for the Web Worker message communication interface.
*
* @param worker The Worker to communicate with (e.g., created with `new Worker(...)`), or the global scope (i.e.,
* `self`) if the current execution context is in a Worker. Defaults to the global scope.
*/
export function createWebWorkerMessageTransports(worker: Worker = globalWorkerScope()): MessageTransports {
const reader = new WebWorkerMessageReader(worker)
const writer = new WebWorkerMessageWriter(worker)
reader.onError(err => console.error(err))
writer.onError(err => console.error(err))
return {
reader,
writer,
}
}
function globalWorkerScope(): Worker {
const worker: Worker = global as any
// tslint:disable-next-line no-unbound-method
if (!worker.postMessage || 'document' in worker) {
throw new Error('global scope is not a Worker')
}
return worker
}

View File

@ -1,4 +1,23 @@
import { Subscribable } from 'sourcegraph'
import { ProxiedObject, ProxyValue } from '@sourcegraph/comlink'
import { Subscription } from 'rxjs'
import { Subscribable, Unsubscribable } from 'sourcegraph'
/**
* Creates a synchronous Subscription that will unsubscribe the given proxied Subscription asynchronously.
*
* @param subscriptionPromise A Promise for a Subscription proxied from the other thread
*/
export const syncSubscription = (
subscriptionPromise: Promise<ProxiedObject<Unsubscribable & ProxyValue>>
): Subscription =>
// We cannot pass the proxy subscription directly to Rx because it is a Proxy that looks like a function
new Subscription(() => {
// tslint:disable-next-line: no-floating-promises
subscriptionPromise.then(proxySubscription => {
// tslint:disable-next-line: no-floating-promises
proxySubscription.unsubscribe()
})
})
/**
* Runs f and returns a resolved promise with its value or a rejected promise with its exception,

View File

@ -6,7 +6,6 @@ import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators
import { ExecutableExtension } from '../api/client/services/extensionsService'
import { Link } from '../components/Link'
import { PopoverButton } from '../components/PopoverButton'
import { Toggle } from '../components/Toggle'
import { ExtensionsControllerProps } from '../extensions/controller'
import { PlatformContextProps } from '../platform/context'
import { asError, ErrorLike, isErrorLike } from '../util/errors'
@ -19,8 +18,6 @@ interface State {
/** The extension IDs of extensions that are active, an error, or undefined while loading. */
extensionsOrError?: Pick<ExecutableExtension, 'id'>[] | ErrorLike
/** Whether to log traces of communication with extensions. */
traceExtensionHostCommunication?: boolean
sideloadedExtensionURL?: string | null
}
@ -49,14 +46,6 @@ export class ExtensionStatus extends React.PureComponent<Props, State> {
map(({ platformContext }) => platformContext),
distinctUntilChanged()
)
this.subscriptions.add(
platformContext
.pipe(
switchMap(({ traceExtensionHostCommunication }) => traceExtensionHostCommunication),
map(traceExtensionHostCommunication => ({ traceExtensionHostCommunication }))
)
.subscribe(stateUpdate => this.setState({ ...this.state, ...stateUpdate }))
)
this.subscriptions.add(
platformContext
@ -101,17 +90,6 @@ export class ExtensionStatus extends React.PureComponent<Props, State> {
<LoadingSpinner className="icon-inline" /> Loading extensions...
</span>
)}
<div className="card-body border-top d-flex justify-content-start align-items-center">
<label htmlFor="extension-status__trace" className="mr-2 mb-0">
Log to devtools console{' '}
</label>
<Toggle
id="extension-status__trace"
onToggle={this.onToggleTrace}
value={this.state.traceExtensionHostCommunication}
title="Toggle extension trace logging to devtools console"
/>
</div>
<div className="card-body border-top">
<h6>Sideload extension</h6>
{this.state.sideloadedExtensionURL ? (
@ -149,10 +127,6 @@ export class ExtensionStatus extends React.PureComponent<Props, State> {
)
}
private onToggleTrace = () => {
this.props.platformContext.traceExtensionHostCommunication.next(!this.state.traceExtensionHostCommunication)
}
private setSideloadedExtensionURL = () => {
const url = window.prompt(
'Parcel dev server URL:',

View File

@ -1,5 +1,5 @@
import { combineLatest, from, Observable, Subject, Subscription, Unsubscribable } from 'rxjs'
import { distinctUntilChanged, map, share, switchMap, tap } from 'rxjs/operators'
import { from, Observable, Subject, Subscription, Unsubscribable } from 'rxjs'
import { map } from 'rxjs/operators'
import { createExtensionHostClient } from '../api/client/client'
import { Services } from '../api/client/services'
import { ExecuteCommandParams } from '../api/client/services/command'
@ -8,8 +8,6 @@ import { ExtensionsService } from '../api/client/services/extensionsService'
import { MessageType } from '../api/client/services/notifications'
import { InitData } from '../api/extension/extensionHost'
import { Contributions } from '../api/protocol'
import { createConnection } from '../api/protocol/jsonrpc2/connection'
import { BrowserConsoleTracer } from '../api/protocol/jsonrpc2/trace'
import { registerBuiltinClientCommands } from '../commands/commands'
import { Notification } from '../notifications/notification'
import { PlatformContext } from '../platform/context'
@ -67,30 +65,12 @@ export function createController(context: PlatformContext): Controller {
const subscriptions = new Subscription()
const services = new Services(context)
const extensionHostConnection = combineLatest(
context.createExtensionHost().pipe(
switchMap(async messageTransports => {
const connection = createConnection(messageTransports)
connection.listen()
const initData: InitData = {
sourcegraphURL: context.sourcegraphURL,
clientApplication: context.clientApplication,
}
await connection.sendRequest('initialize', [initData])
return connection
}),
share()
),
context.traceExtensionHostCommunication
).pipe(
tap(([connection, trace]) => {
connection.trace(trace ? new BrowserConsoleTracer('') : null)
}),
map(([connection]) => connection),
distinctUntilChanged()
)
const client = createExtensionHostClient(services, extensionHostConnection)
const extensionHostEndpoint = context.createExtensionHost()
const initData: InitData = {
sourcegraphURL: context.sourcegraphURL,
clientApplication: context.clientApplication,
}
const client = createExtensionHostClient(services, extensionHostEndpoint, initData)
subscriptions.add(client)
const notifications = new Subject<Notification>()

11
shared/src/globals.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
/**
* For Web Worker entrypoints using Webpack's worker-loader.
*
* See https://github.com/webpack-contrib/worker-loader#integrating-with-typescript.
*/
declare module 'worker-loader?inline!*' {
class WebpackWorker extends Worker {
constructor()
}
export default WebpackWorker
}

View File

@ -1,11 +1,21 @@
import { Endpoint, isEndpoint } from '@sourcegraph/comlink'
import { NextObserver, Observable, Subscribable } from 'rxjs'
import { SettingsEdit } from '../api/client/services/settings'
import { MessageTransports } from '../api/protocol/jsonrpc2/connection'
import { GraphQLResult } from '../graphql/graphql'
import * as GQL from '../graphql/schema'
import { Settings, SettingsCascadeOrError } from '../settings/settings'
import { FileSpec, PositionSpec, RepoSpec, RevSpec, ViewStateSpec } from '../util/url'
export interface EndpointPair {
/** The endpoint to proxy the API of the other thread from */
proxy: Endpoint & Pick<MessagePort, 'start'>
/** The endpoint to expose the API of this thread to */
expose: Endpoint & Pick<MessagePort, 'start'>
}
export const isEndpointPair = (val: any): val is EndpointPair =>
typeof val === 'object' && val !== null && isEndpoint(val.proxy) && isEndpoint(val.expose)
/**
* Platform-specific data and methods shared by multiple Sourcegraph components.
*
@ -68,7 +78,7 @@ export interface PlatformContext {
* @returns An observable that emits at most once with the message transports for communicating
* with the execution context (using, e.g., postMessage/onmessage) when it is ready.
*/
createExtensionHost(): Observable<MessageTransports>
createExtensionHost(): Observable<EndpointPair>
/**
* Returns the script URL suitable for passing to importScripts for an extension's bundle.
@ -116,11 +126,6 @@ export interface PlatformContext {
*/
clientApplication: 'sourcegraph' | 'other'
/**
* Whether to log all messages sent between the client and the extension host.
*/
traceExtensionHostCommunication: Subscribable<boolean> & NextObserver<boolean>
/**
* The URL to the Parcel dev server for a single extension.
* Used for extension development purposes, to run an extension that isn't on the registry.

View File

@ -1,3 +1,5 @@
import 'symbol-observable'
// This gets automatically expanded into
// imports that only pick what we need
import '@babel/polyfill'
@ -5,4 +7,5 @@ import '@babel/polyfill'
// Polyfill URL because Chrome and Firefox are not spec-compliant
// Hostnames of URIs with custom schemes (e.g. git) are not parsed out
import { URL, URLSearchParams } from 'whatwg-url'
Object.assign(window, { URL, URLSearchParams })
Object.assign(URL, self.URL) // keep static methods like URL.createObjectURL()
Object.assign(self, { URL, URLSearchParams })

View File

@ -3,7 +3,7 @@
// Order is important here
// Don't remove the empty lines between these imports
import '../polyfills'
import '../../../shared/src/polyfills'
import '../sentry'

View File

@ -119,7 +119,7 @@ interface SourcegraphContext {
*
* See https://github.com/webpack-contrib/worker-loader#integrating-with-typescript.
*/
declare module 'worker-loader!*' {
declare module 'worker-loader?inline!*' {
class WebpackWorker extends Worker {
constructor()
}

View File

@ -3,7 +3,7 @@
// Order is important here
// Don't remove the empty lines between these imports
import './polyfills'
import '../../shared/src/polyfills'
import './sentry'

View File

@ -1,7 +1,6 @@
import { concat, Observable, ReplaySubject } from 'rxjs'
import { concat, ReplaySubject } from 'rxjs'
import { map, publishReplay, refCount } from 'rxjs/operators'
import ExtensionHostWorker from 'worker-loader!../../../shared/src/api/extension/main.worker.ts'
import { createWebWorkerMessageTransports } from '../../../shared/src/api/protocol/jsonrpc2/transports/webWorker'
import { createExtensionHost } from '../../../shared/src/api/extension/worker'
import { gql } from '../../../shared/src/graphql/graphql'
import * as GQL from '../../../shared/src/graphql/schema'
import { PlatformContext } from '../../../shared/src/platform/context'
@ -61,19 +60,11 @@ export function createPlatformContext(): PlatformContext {
variables
),
forceUpdateTooltip: () => Tooltip.forceUpdate(),
createExtensionHost: () => {
const worker = new ExtensionHostWorker()
const messageTransports = createWebWorkerMessageTransports(worker)
return new Observable(sub => {
sub.next(messageTransports)
return () => worker.terminate()
})
},
createExtensionHost: () => createExtensionHost({ wrapEndpoints: false }),
urlToFile: toPrettyBlobURL,
getScriptURLForExtension: bundleURL => bundleURL,
sourcegraphURL: window.context.externalURL,
clientApplication: 'sourcegraph',
traceExtensionHostCommunication: new LocalStorageSubject<boolean>('traceExtensionHostCommunication', false),
sideloadedExtensionURL: new LocalStorageSubject<string | null>('sideloadedExtensionURL', null),
}
return context

View File

@ -1488,6 +1488,11 @@
rxjs "^6.3.3"
ts-key-enum "^2.0.0"
"@sourcegraph/comlink@^3.1.1-fork.3":
version "3.1.1-fork.3"
resolved "https://registry.npmjs.org/@sourcegraph/comlink/-/comlink-3.1.1-fork.3.tgz#1bd76bc74ca8a9b55f1dbf508c703053ee66c961"
integrity sha512-+vfSCJTS/GU391Hhdh2/s+wy5ok4hi6X+kizRQqQZwZI03Q1rR6SVLNFsbg4dhbX624mqr1UCRhwNnZdWHAR+A==
"@sourcegraph/event-positions@^1.0.1":
version "1.0.1"
resolved "https://registry.npmjs.org/@sourcegraph/event-positions/-/event-positions-1.0.1.tgz#aa83c38170275db4e696a6d5fc0f4d1e733713be"
@ -1505,9 +1510,8 @@
sourcegraph "*"
"@sourcegraph/extension-api-types@link:packages/@sourcegraph/extension-api-types":
version "1.1.0"
dependencies:
sourcegraph "*"
version "0.0.0"
uid ""
"@sourcegraph/prettierrc@^2.2.0":
version "2.2.0"
@ -10178,6 +10182,11 @@ mermaid@^8.0.0:
moment "^2.23.0"
scope-css "^1.2.1"
message-port-polyfill@^0.1.0:
version "0.1.0"
resolved "https://registry.npmjs.org/message-port-polyfill/-/message-port-polyfill-0.1.0.tgz#0d6c9201e5e15301f36930c4b7fe0a56c17bc7a7"
integrity sha512-CKP0YTyLUnGZU+1SCqIm/QFE/oqo0oA4mLTb6BWSx5/YPlvlut32dpmYSr8u55xqAzy89Tc9X7bMdSwXtZJ2Ew==
methods@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
@ -13780,12 +13789,13 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
sourcegraph@*:
version "19.4.0"
resolved "https://registry.npmjs.org/sourcegraph/-/sourcegraph-19.4.0.tgz#5e02146ef06a5a3b0e37d1f09375bbfa011ddee8"
integrity sha512-fyx0nFtySTdn+Y1JR7LPdl2lj9vHCbSgoLjzMEW5iuBjVuVHXdVldq3kjBYoI98MI6L53qATT993sDVrq4Jqwg==
version "21.0.0"
resolved "https://registry.npmjs.org/sourcegraph/-/sourcegraph-21.0.0.tgz#b84ef37a0655574759d5f4a7ee5c1da388111580"
integrity sha512-PcsvCepo3vORtzKgcjeaXMQcL+BQj2OO/Lkq7S0/k5bexefn6Wf5WrFD3SXJFqPVOWJM1hPG1tw1ME9Z5Gne3Q==
"sourcegraph@link:packages/sourcegraph-extension-api":
version "22.0.0"
version "0.0.0"
uid ""
space-separated-tokens@^1.0.0:
version "1.1.2"