@callstack/repack-dev-server
Version:
A bundler-agnostic development server for React Native applications as part of @callstack/repack.
318 lines (317 loc) • 11.3 kB
JavaScript
import { URL } from 'node:url';
import { WebSocketServer } from '../WebSocketServer.js';
/**
* Class for creating a WebSocket server and sending messages between development server
* and the React Native applications.
*
* Based on: https://github.com/react-native-community/cli/blob/v4.14.0/packages/cli-server-api/src/websocket/messageSocketServer.ts
*
* @category Development server
*/
export class WebSocketMessageServer extends WebSocketServer {
/**
* Check if message is a broadcast request.
*
* @param message Message to check.
* @returns True if message is a broadcast request and should be broadcasted
* with {@link sendBroadcast}.
*/
static isBroadcast(message) {
return (typeof message.method === 'string' &&
message.id === undefined &&
message.target === undefined);
}
/**
* Check if message is a method request.
*
* @param message Message to check.
* @returns True if message is a request.
*/
static isRequest(message) {
return (typeof message.method === 'string' && typeof message.target === 'string');
}
/**
* Check if message is a response with results of performing some request.
*
* @param message Message to check.
* @returns True if message is a response.
*/
static isResponse(message) {
return (typeof message.id === 'object' &&
typeof message.id.requestId !== 'undefined' &&
typeof message.id.clientId === 'string' &&
(message.result !== undefined || message.error !== undefined));
}
/**
* Create new instance of WebSocketMessageServer and attach it to the given Fastify instance.
* Any logging information, will be passed through standard `fastify.log` API.
*
* @param fastify Fastify instance to attach the WebSocket server to.
*/
constructor(fastify) {
super(fastify, { name: 'Message', path: '/message' });
this.upgradeRequests = {};
}
/**
* Parse stringified message into a {@link ReactNativeMessage}.
*
* @param data Stringified message.
* @param binary Additional binary data if any.
* @returns Parsed message or `undefined` if parsing failed.
*/
parseMessage(data, binary) {
if (binary) {
this.fastify.log.error({
msg: 'Failed to parse message - expected text message, got binary',
});
return undefined;
}
try {
const message = JSON.parse(data);
if (message.version === WebSocketMessageServer.PROTOCOL_VERSION.toString()) {
return message;
}
this.fastify.log.error({
msg: 'Received message had wrong protocol version',
message,
});
}
catch {
this.fastify.log.error({
msg: 'Failed to parse the message as JSON',
data,
});
}
return undefined;
}
/**
* Get client's WebSocket connection for given `clientId`.
* Throws if no such client is connected.
*
* @param clientId Id of the client.
* @returns WebSocket connection.
*/
getClientSocket(clientId) {
const socket = this.clients.get(clientId);
if (socket === undefined) {
throw new Error(`Could not find client with id "${clientId}"`);
}
return socket;
}
/**
* Process error by sending an error message to the client whose message caused the error
* to occur.
*
* @param clientId Id of the client whose message caused an error.
* @param message Original message which caused the error.
* @param error Concrete instance of an error that occurred.
*/
handleError(clientId, message, error) {
const errorMessage = {
id: message.id,
method: message.method,
target: message.target,
error: message.error === undefined ? 'undefined' : 'defined',
params: message.params === undefined ? 'undefined' : 'defined',
result: message.result === undefined ? 'undefined' : 'defined',
};
if (message.id === undefined) {
this.fastify.log.error({
msg: 'Handling message failed',
clientId,
error,
errorMessage,
});
}
else {
try {
const socket = this.getClientSocket(clientId);
socket.send(JSON.stringify({
version: WebSocketMessageServer.PROTOCOL_VERSION,
error,
id: message.id,
}));
}
catch (error) {
this.fastify.log.error('Failed to reply', {
clientId,
error,
errorMessage,
});
}
}
}
/**
* Send given request `message` to it's designated client's socket based on `message.target`.
* The target client must be connected, otherwise it will throw an error.
*
* @param clientId Id of the client that requested the forward.
* @param message Message to forward.
*/
forwardRequest(clientId, message) {
if (!message.target) {
this.fastify.log.error({
msg: 'Failed to forward request - message.target is missing',
clientId,
message,
});
return;
}
const socket = this.getClientSocket(message.target);
socket.send(JSON.stringify({
version: WebSocketMessageServer.PROTOCOL_VERSION,
method: message.method,
params: message.params,
id: message.id === undefined
? undefined
: { requestId: message.id, clientId },
}));
}
/**
* Send given response `message` to it's designated client's socket based
* on `message.id.clientId`.
* The target client must be connected, otherwise it will throw an error.
*
* @param message Message to forward.
*/
forwardResponse(message) {
if (!message.id) {
return;
}
const socket = this.getClientSocket(message.id.clientId);
socket.send(JSON.stringify({
version: WebSocketMessageServer.PROTOCOL_VERSION,
result: message.result,
error: message.error,
id: message.id.requestId,
}));
}
/**
* Process request message targeted towards this {@link WebSocketMessageServer}
* and send back the results.
*
* @param clientId Id of the client who send the message.
* @param message The message to process by the server.
*/
processServerRequest(clientId, message) {
let result;
switch (message.method) {
case 'getid':
result = clientId;
break;
case 'getpeers': {
const output = {};
this.clients.forEach((_, peerId) => {
if (clientId !== peerId) {
const { searchParams } = new URL(this.upgradeRequests[peerId]?.url || '');
output[peerId] = [...searchParams.entries()].reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {});
}
});
result = output;
break;
}
default:
throw new Error(`Cannot process server request - unknown method ${JSON.stringify({
clientId,
message,
})}`);
}
const socket = this.getClientSocket(clientId);
socket.send(JSON.stringify({
version: WebSocketMessageServer.PROTOCOL_VERSION,
result,
id: message.id,
}));
}
/**
* Broadcast given message to all connected clients.
*
* @param broadcasterId Id of the client who is broadcasting.
* @param message Message to broadcast.
*/
sendBroadcast(broadcasterId, message) {
const forwarded = {
version: WebSocketMessageServer.PROTOCOL_VERSION,
method: message.method,
params: message.params,
};
if (this.clients.size === 0) {
this.fastify.log.warn({
msg: 'No apps connected. ' +
`Sending "${message.method}" to all React Native apps failed. ` +
'Make sure your app is running in the simulator or on a phone connected via USB.',
});
}
for (const [clientId, socket] of this.clients) {
if (clientId !== broadcasterId) {
try {
socket.send(JSON.stringify(forwarded));
}
catch (error) {
this.fastify.log.error({
msg: 'Failed to send broadcast',
clientId,
error,
forwarded,
});
}
}
}
}
/**
* Send method broadcast to all connected clients.
*
* @param method Method name to broadcast.
* @param params Method parameters.
*/
broadcast(method, params) {
this.sendBroadcast(undefined, { method, params });
}
onConnection(socket, request) {
const clientId = super.onConnection(socket, request);
this.upgradeRequests[clientId] = request;
socket.addEventListener('message', (event) => {
const message = this.parseMessage(event.data.toString(),
// @ts-ignore
event.binary);
if (!message) {
this.fastify.log.error({
msg: 'Received message not matching protocol',
clientId,
message,
});
return;
}
try {
if (WebSocketMessageServer.isBroadcast(message)) {
this.sendBroadcast(clientId, message);
}
else if (WebSocketMessageServer.isRequest(message)) {
if (message.target === 'server') {
this.processServerRequest(clientId, message);
}
else {
this.forwardRequest(clientId, message);
}
}
else if (WebSocketMessageServer.isResponse(message)) {
this.forwardResponse(message);
}
else {
throw new Error(`Invalid message, did not match the protocol ${JSON.stringify({
clientId,
message,
})}`);
}
}
catch (error) {
this.handleError(clientId, message, error);
}
});
return clientId;
}
}
WebSocketMessageServer.PROTOCOL_VERSION = 2;