Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion flow-typed/npm/ws_v7.x.x.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ declare class ws$WebSocketServer extends events$EventEmitter {
perMessageDeflate?: boolean | ws$PerMessageDeflateOptions,
port?: number,
server?: http$Server | https$Server,
verifyClient?: () => unknown,
verifyClient?: (
info: Readonly<{
origin: string,
secure: boolean,
req: http$IncomingMessage<>,
}>,
) => unknown,
},
callback?: () => unknown,
): this;
Expand Down
4 changes: 3 additions & 1 deletion packages/community-cli-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
"metro": "^0.87.2",
"nocache": "^3.0.1",
"open": "^8.4.2",
"semver": "^7.1.3"
"pretty-format": "^29.7.0",
"semver": "^7.1.3",
"ws": "^7.5.10"
},
"devDependencies": {
"metro-resolver": "^0.87.2"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import http from 'node:http';
import WebSocket from 'ws';

export type TestServer = Readonly<{
baseUrl: URL,
mount: (path: string, wss: ws$WebSocketServer) => void,
server: http$Server,
connect: (
path: string,
options?: Readonly<{origin?: string}>,
) => Promise<TestClient>,
close: () => Promise<void>,
}>;

export type TestClient = Readonly<{
send: (message: unknown) => void,
sendRaw: (data: string | Buffer) => void,
next: () => Promise<unknown>,
/**
* Resolves with the messages received before the server answers a `getid`
* request, which it handles after everything this client was sent earlier.
*/
drain: () => Promise<Array<unknown>>,
getId: () => Promise<string>,
close: () => void,
}>;

/**
* Retry an assertion until it passes, for an effect the server applies after
* the client has already sent its message.
*/
export async function waitUntil(assertion: () => void): Promise<void> {
const deadline = Date.now() + 2000;
for (;;) {
try {
assertion();
return;
} catch (e) {
if (Date.now() > deadline) {
throw e;
}
await new Promise(resolve => setTimeout(resolve, 10));
}
}
}

export async function startTestServer(): Promise<TestServer> {
const endpoints = new Map<string, ws$WebSocketServer>();
const sockets = new Set<WebSocket>();
const server = http.createServer();
server.on('upgrade', (req, socket, head) => {
const wss = endpoints.get(new URL(req.url, 'http://localhost').pathname);
if (wss == null) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req));
});
await new Promise(resolve => server.listen(0, 'localhost', resolve));
const address = server.address();
if (address == null || typeof address === 'string') {
throw new Error('Expected the server to listen on a TCP port');
}
const baseUrl = new URL(`http://localhost:${address.port}`);

return {
baseUrl,
mount: (path, wss) => {
endpoints.set(path, wss);
},
server,
connect: async (path, options) => {
const socket = new WebSocket(`ws://localhost:${address.port}${path}`, {
headers: options?.origin == null ? {} : {Origin: options.origin},
});
sockets.add(socket);
const received: Array<unknown> = [];
const waiting: Array<(message: unknown) => void> = [];
socket.on('message', data => {
const message = JSON.parse(String(data));
const resolve = waiting.shift();
if (resolve != null) {
resolve(message);
} else {
received.push(message);
}
});
await new Promise((resolve, reject) => {
socket.once('open', resolve);
socket.once('error', reject);
socket.once('unexpected-response', (req, res) =>
reject(new Error(`HTTP ${res.statusCode}`)),
);
});

const next = (): Promise<unknown> =>
received.length > 0
? Promise.resolve(received.shift())
: new Promise(resolve => waiting.push(resolve));

return {
send: message => socket.send(JSON.stringify(message)),
sendRaw: data => socket.send(data),
next,
drain: async () => {
socket.send(
JSON.stringify({
version: 2,
method: 'getid',
target: 'server',
id: 'drain',
}),
);
const before = [];
let message = await next();
while (!isReplyTo(message, 'drain')) {
before.push(message);
message = await next();
}
return before;
},
getId: async () => {
socket.send(
JSON.stringify({
version: 2,
method: 'getid',
target: 'server',
id: 'getid',
}),
);
const message = await next();
if (
message == null ||
typeof message !== 'object' ||
message.id !== 'getid' ||
typeof message.result !== 'string'
) {
throw new Error('Expected a getid reply');
}
return message.result;
},
close: () => socket.close(),
};
},
close: async () => {
sockets.forEach(socket => socket.terminate());
server.closeAllConnections();
await new Promise(resolve => server.close(() => resolve()));
},
};
}

function isReplyTo(message: unknown, id: string): boolean {
return message != null && typeof message === 'object' && message.id === id;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ beforeEach(async () => {
serverBaseUrl: new URL(baseUrl),
projectRoot: '/project',
watchFolders: ['/project'],
}),
logger: {warn: () => {}, error: () => {}},
}).middleware,
)
.use((req, res) => {
res.writeHead(404);
Expand Down Expand Up @@ -62,3 +63,34 @@ describe('createDevServerMiddleware', () => {
expect(resp.status).toBe(403);
});
});

describe('createDevServerMiddleware sockets', () => {
const {startTestServer} = require('./SocketTestUtils');
let testServer;

beforeEach(async () => {
const connect = require('connect');
const createDevServerMiddleware =
require('../createDevServerMiddleware').default;
testServer = await startTestServer();
const {middleware, websocketEndpoints} = createDevServerMiddleware({
serverBaseUrl: testServer.baseUrl,
projectRoot: '/project',
watchFolders: ['/project'],
logger: {warn: () => {}, error: () => {}},
});
testServer.server.on('request', connect().use(middleware));
testServer.mount('/message', websocketEndpoints['/message']);
});

afterEach(() => testServer.close());

test('reloads the connected apps on /reload', async () => {
const app = await testServer.connect('/message');

const resp = await fetch(testServer.baseUrl.origin + '/reload');

expect(await resp.text()).toBe('OK');
expect(await app.next()).toEqual({version: 2, method: 'reload'});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import type {TestServer} from './SocketTestUtils';

import createEventsSocket from '../createEventsSocket';
import {startTestServer, waitUntil} from './SocketTestUtils';

jest.useRealTimers();

const logger = {warn: jest.fn(), error: jest.fn()};
const broadcast = jest.fn<[string, ?{[string]: unknown}], void>();

let testServer: TestServer;
let eventsSocket: ReturnType<typeof createEventsSocket>;

beforeEach(async () => {
logger.warn.mockReset();
logger.error.mockReset();
broadcast.mockReset();
testServer = await startTestServer();
eventsSocket = createEventsSocket({
logger,
serverBaseUrl: testServer.baseUrl,
broadcast,
});
testServer.mount('/events', eventsSocket.server);
});

afterEach(() => testServer.close());

describe('/events', () => {
test('sends a reported event to every client', async () => {
const first = await testServer.connect('/events');
const second = await testServer.connect('/events');

eventsSocket.reportEvent({type: 'bundle_build_started', id: 'a'});

const expected = {type: 'bundle_build_started', id: 'a'};
expect(await first.next()).toEqual(expected);
expect(await second.next()).toEqual(expected);
});

test('formats the error of an event', async () => {
const client = await testServer.connect('/events');

eventsSocket.reportEvent({
type: 'bundling_error',
error: new Error('boom'),
});

expect(await client.next()).toEqual({
type: 'bundling_error',
error: expect.stringContaining('boom'),
});
});

test('logs an event that cannot be serialized', async () => {
const client = await testServer.connect('/events');
const circular: {[string]: unknown} = {};
circular.self = circular;

eventsSocket.reportEvent(circular);
eventsSocket.reportEvent({type: 'after'});

expect(await client.next()).toEqual({type: 'after'});
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to serialize'),
);
});

test('forwards a command to the connected apps', async () => {
const client = await testServer.connect('/events');

client.send({
version: 2,
type: 'command',
command: 'reload',
params: {a: 1},
});

await waitUntil(() =>
expect(broadcast).toHaveBeenCalledWith('reload', {a: 1}),
);
});

test.each([
['a message that is not JSON', 'not json', 'Failed to parse the message'],
[
'a message with another protocol version',
'{"version":1,"type":"command","command":"reload"}',
'wrong protocol version: 1',
],
])('logs and ignores %s', async (_, raw, logged) => {
const client = await testServer.connect('/events');

client.sendRaw(raw);
client.send({version: 2, type: 'command', command: 'devMenu'});
await waitUntil(() => expect(broadcast).toHaveBeenCalledTimes(1));

expect(broadcast).toHaveBeenCalledWith('devMenu', undefined);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(logged));
});

test.each([
['no origin', undefined],
['the dev server origin', 'SERVER'],
['a file origin', 'file://'],
])('accepts a client with %s', async (_, origin) => {
const client = await testServer.connect('/events', {
origin: origin === 'SERVER' ? testServer.baseUrl.origin : origin,
});

eventsSocket.reportEvent({type: 'x'});

expect(await client.next()).toEqual({type: 'x'});
});

test('refuses a client with a foreign origin', async () => {
await expect(
testServer.connect('/events', {origin: 'https://example.com'}),
).rejects.toThrow('HTTP 401');
});
});
Loading
Loading