expo
Version:
241 lines (220 loc) • 7.7 kB
text/typescript
import { MessageFramePacker } from './MessageFramePacker';
import { WebSocketBackingStore } from './WebSocketBackingStore';
import { WebSocketWithReconnect } from './WebSocketWithReconnect';
import type {
ConnectionInfo,
DevToolsPluginClientOptions,
HandshakeMessageParams,
} from './devtools.types';
import * as logger from './logger';
import { blobToArrayBufferAsync } from '../utils/blobUtils';
interface MessageFramePackerMessageKey {
pluginName: string;
method: string;
}
export interface EventSubscription {
remove(): void;
}
/**
* This client is for the Expo DevTools Plugins to communicate between the app and the DevTools webpage hosted in a browser.
* All the code should be both compatible with browsers and React Native.
*/
export abstract class DevToolsPluginClient {
private listeners: Record<string, undefined | Set<(params: any) => void>>;
private static defaultWSStore: WebSocketBackingStore = new WebSocketBackingStore();
private readonly wsStore: WebSocketBackingStore = DevToolsPluginClient.defaultWSStore;
protected isClosed = false;
protected retries = 0;
private readonly messageFramePacker: MessageFramePacker<MessageFramePackerMessageKey> =
new MessageFramePacker();
public constructor(
public readonly connectionInfo: ConnectionInfo,
private readonly options?: DevToolsPluginClientOptions
) {
this.wsStore = connectionInfo.wsStore || DevToolsPluginClient.defaultWSStore;
this.listeners = Object.create(null);
}
/**
* Initialize the connection.
* @hidden
*/
public async initAsync(): Promise<void> {
if (this.wsStore.ws == null) {
this.wsStore.ws = await this.connectAsync();
}
this.wsStore.refCount += 1;
this.wsStore.ws.addEventListener('message', this.handleMessage);
}
/**
* Close the connection.
*/
public async closeAsync(): Promise<void> {
this.isClosed = true;
this.wsStore.ws?.removeEventListener('message', this.handleMessage);
this.wsStore.refCount -= 1;
if (this.wsStore.refCount < 1) {
this.wsStore.ws?.close();
this.wsStore.ws = null;
}
this.listeners = Object.create(null);
}
/**
* Send a message to the other end of DevTools.
* @param method A method name.
* @param params any extra payload.
*/
public sendMessage(method: string, params: any) {
if (this.wsStore.ws?.readyState === WebSocket.CLOSED) {
logger.warn('Unable to send message in a disconnected state.');
return;
}
const messageKey: MessageFramePackerMessageKey = {
pluginName: this.connectionInfo.pluginName,
method,
};
const packedData = this.messageFramePacker.pack({ messageKey, payload: params });
if (!(packedData instanceof Promise)) {
this.wsStore.ws?.send(packedData);
return;
}
packedData.then((data) => {
this.wsStore.ws?.send(data);
});
}
/**
* Subscribe to a message from the other end of DevTools.
* @param method Subscribe to a message with a method name.
* @param listener Listener to be called when a message is received.
*/
public addMessageListener(method: string, listener: (params: any) => void): EventSubscription {
const listenersForMethod = this.listeners[method] || (this.listeners[method] = new Set());
listenersForMethod.add(listener);
return {
remove: () => {
this.listeners[method]?.delete(listener);
},
};
}
/**
* Subscribe to a message from the other end of DevTools just once.
* @param method Subscribe to a message with a method name.
* @param listener Listener to be called when a message is received.
*/
public addMessageListenerOnce(method: string, listener: (params: any) => void): void {
const wrappedListenerOnce = (params: any): void => {
listener(params);
this.listeners[method]?.delete(wrappedListenerOnce);
};
this.addMessageListener(method, wrappedListenerOnce);
}
/**
* Internal handshake message sender.
* @hidden
*/
protected sendHandshakeMessage(params: HandshakeMessageParams) {
if (this.wsStore.ws?.readyState === WebSocket.CLOSED) {
logger.warn('Unable to send message in a disconnected state.');
return;
}
this.wsStore.ws?.send(JSON.stringify({ ...params, __isHandshakeMessages: true }));
}
/**
* Internal handshake message listener.
* @hidden
*/
protected addHandskakeMessageListener(
listener: (params: HandshakeMessageParams) => void
): EventSubscription {
const messageListener = (event: MessageEvent) => {
if (typeof event.data !== 'string') {
// binary data is not coming from the handshake messages.
return;
}
const data = JSON.parse(event.data);
if (!data.__isHandshakeMessages) {
return;
}
delete data.__isHandshakeMessages;
const params = data as HandshakeMessageParams;
if (params.pluginName && params.pluginName !== this.connectionInfo.pluginName) {
return;
}
listener(params);
};
this.wsStore.ws?.addEventListener('message', messageListener);
return {
remove: () => {
this.wsStore.ws?.removeEventListener('message', messageListener);
},
};
}
/**
* Returns whether the client is connected to the server.
*/
public isConnected(): boolean {
return this.wsStore.ws?.readyState === WebSocket.OPEN;
}
/**
* The method to create the WebSocket connection.
*/
protected connectAsync(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const endpoint = 'expo-dev-plugins/broadcast';
const ws = new WebSocketWithReconnect(`ws://${this.connectionInfo.devServer}/${endpoint}`, {
binaryType: this.options?.websocketBinaryType,
onError: (e: unknown) => {
if (e instanceof Error) {
console.warn(`Error happened from the WebSocket connection: ${e.message}\n${e.stack}`);
} else {
console.warn(`Error happened from the WebSocket connection: ${JSON.stringify(e)}`);
}
},
});
ws.addEventListener('open', () => {
resolve(ws);
});
ws.addEventListener('error', (e) => {
reject(e);
});
ws.addEventListener('close', (e: WebSocketCloseEvent) => {
logger.info('WebSocket closed', e.code, e.reason);
});
});
}
protected handleMessage = async (event: WebSocketMessageEvent) => {
let data: ArrayBuffer | string;
if (typeof event.data === 'string') {
data = event.data;
} else if (event.data instanceof ArrayBuffer) {
data = event.data;
} else if (ArrayBuffer.isView(event.data)) {
data = event.data.buffer as ArrayBuffer;
} else if (event.data instanceof Blob) {
data = await blobToArrayBufferAsync(event.data);
} else {
logger.warn('Unsupported received data type in handleMessageImpl');
return;
}
const { messageKey, payload, ...rest } = this.messageFramePacker.unpack(data);
// @ts-expect-error: `__isHandshakeMessages` is a private field that is not part of the MessageFramePacker type.
if (rest?.__isHandshakeMessages === true) {
return;
}
if (messageKey.pluginName && messageKey.pluginName !== this.connectionInfo.pluginName) {
return;
}
const listenersForMethod = this.listeners[messageKey.method];
if (listenersForMethod) {
for (const listener of listenersForMethod) {
listener(payload);
}
}
};
/**
* Get the WebSocket backing store. Exposed for testing.
* @hidden
*/
public getWebSocketBackingStore(): WebSocketBackingStore {
return this.wsStore;
}
}