Skip to content

Commit c5a4e6e

Browse files
authored
Move /message, /events and /reload into community-cli-plugin
1 parent 82dab20 commit c5a4e6e

12 files changed

Lines changed: 1018 additions & 50 deletions

‎flow-typed/npm/ws_v7.x.x.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ declare class ws$WebSocketServer extends events$EventEmitter {
3939
perMessageDeflate?: boolean | ws$PerMessageDeflateOptions,
4040
port?: number,
4141
server?: http$Server | https$Server,
42-
verifyClient?: () => unknown,
42+
verifyClient?: (
43+
info: Readonly<{
44+
origin: string,
45+
secure: boolean,
46+
req: http$IncomingMessage<>,
47+
}>,
48+
) => unknown,
4349
},
4450
callback?: () => unknown,
4551
): this;

‎packages/community-cli-plugin/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@
4343
"launch-editor": "^2.9.1",
4444
"nocache": "^3.0.1",
4545
"open": "^8.4.2",
46-
"semver": "^7.1.3"
46+
"pretty-format": "^29.7.0",
47+
"semver": "^7.1.3",
48+
"ws": "^7.5.10"
4749
},
4850
"devDependencies": {
4951
"metro-resolver": "^0.88.0"
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import http from 'node:http';
12+
import WebSocket from 'ws';
13+
14+
export type TestServer = Readonly<{
15+
baseUrl: URL,
16+
mount: (path: string, wss: ws$WebSocketServer) => void,
17+
server: http$Server,
18+
connect: (
19+
path: string,
20+
options?: Readonly<{origin?: string}>,
21+
) => Promise<TestClient>,
22+
close: () => Promise<void>,
23+
}>;
24+
25+
export type TestClient = Readonly<{
26+
send: (message: unknown) => void,
27+
sendRaw: (data: string | Buffer) => void,
28+
next: () => Promise<unknown>,
29+
/**
30+
* Resolves with the messages received before the server answers a `getid`
31+
* request, which it handles after everything this client was sent earlier.
32+
*/
33+
drain: () => Promise<Array<unknown>>,
34+
getId: () => Promise<string>,
35+
close: () => void,
36+
}>;
37+
38+
/**
39+
* Retry an assertion until it passes, for an effect the server applies after
40+
* the client has already sent its message.
41+
*/
42+
export async function waitUntil(assertion: () => void): Promise<void> {
43+
const deadline = Date.now() + 2000;
44+
for (;;) {
45+
try {
46+
assertion();
47+
return;
48+
} catch (e) {
49+
if (Date.now() > deadline) {
50+
throw e;
51+
}
52+
await new Promise(resolve => setTimeout(resolve, 10));
53+
}
54+
}
55+
}
56+
57+
export async function startTestServer(): Promise<TestServer> {
58+
const endpoints = new Map<string, ws$WebSocketServer>();
59+
const sockets = new Set<WebSocket>();
60+
const server = http.createServer();
61+
server.on('upgrade', (req, socket, head) => {
62+
const wss = endpoints.get(new URL(req.url, 'http://localhost').pathname);
63+
if (wss == null) {
64+
socket.destroy();
65+
return;
66+
}
67+
wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req));
68+
});
69+
await new Promise(resolve => server.listen(0, 'localhost', resolve));
70+
const address = server.address();
71+
if (address == null || typeof address === 'string') {
72+
throw new Error('Expected the server to listen on a TCP port');
73+
}
74+
const baseUrl = new URL(`http://localhost:${address.port}`);
75+
76+
return {
77+
baseUrl,
78+
mount: (path, wss) => {
79+
endpoints.set(path, wss);
80+
},
81+
server,
82+
connect: async (path, options) => {
83+
const socket = new WebSocket(`ws://localhost:${address.port}${path}`, {
84+
headers: options?.origin == null ? {} : {Origin: options.origin},
85+
});
86+
sockets.add(socket);
87+
const received: Array<unknown> = [];
88+
const waiting: Array<(message: unknown) => void> = [];
89+
socket.on('message', data => {
90+
const message = JSON.parse(String(data));
91+
const resolve = waiting.shift();
92+
if (resolve != null) {
93+
resolve(message);
94+
} else {
95+
received.push(message);
96+
}
97+
});
98+
await new Promise((resolve, reject) => {
99+
socket.once('open', resolve);
100+
socket.once('error', reject);
101+
socket.once('unexpected-response', (req, res) =>
102+
reject(new Error(`HTTP ${res.statusCode}`)),
103+
);
104+
});
105+
106+
const next = (): Promise<unknown> =>
107+
received.length > 0
108+
? Promise.resolve(received.shift())
109+
: new Promise(resolve => waiting.push(resolve));
110+
111+
return {
112+
send: message => socket.send(JSON.stringify(message)),
113+
sendRaw: data => socket.send(data),
114+
next,
115+
drain: async () => {
116+
socket.send(
117+
JSON.stringify({
118+
version: 2,
119+
method: 'getid',
120+
target: 'server',
121+
id: 'drain',
122+
}),
123+
);
124+
const before = [];
125+
let message = await next();
126+
while (!isReplyTo(message, 'drain')) {
127+
before.push(message);
128+
message = await next();
129+
}
130+
return before;
131+
},
132+
getId: async () => {
133+
socket.send(
134+
JSON.stringify({
135+
version: 2,
136+
method: 'getid',
137+
target: 'server',
138+
id: 'getid',
139+
}),
140+
);
141+
const message = await next();
142+
if (
143+
message == null ||
144+
typeof message !== 'object' ||
145+
message.id !== 'getid' ||
146+
typeof message.result !== 'string'
147+
) {
148+
throw new Error('Expected a getid reply');
149+
}
150+
return message.result;
151+
},
152+
close: () => socket.close(),
153+
};
154+
},
155+
close: async () => {
156+
sockets.forEach(socket => socket.terminate());
157+
server.closeAllConnections();
158+
await new Promise(resolve => server.close(() => resolve()));
159+
},
160+
};
161+
}
162+
163+
function isReplyTo(message: unknown, id: string): boolean {
164+
return message != null && typeof message === 'object' && message.id === id;
165+
}

‎packages/community-cli-plugin/src/dev-server/__tests__/createDevServerMiddleware-test.js‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ beforeEach(async () => {
3333
serverBaseUrl: new URL(baseUrl),
3434
projectRoot: '/project',
3535
watchFolders: ['/project'],
36-
}),
36+
logger: {warn: () => {}, error: () => {}},
37+
}).middleware,
3738
)
3839
.use((req, res) => {
3940
res.writeHead(404);
@@ -62,3 +63,34 @@ describe('createDevServerMiddleware', () => {
6263
expect(resp.status).toBe(403);
6364
});
6465
});
66+
67+
describe('createDevServerMiddleware sockets', () => {
68+
const {startTestServer} = require('./SocketTestUtils');
69+
let testServer;
70+
71+
beforeEach(async () => {
72+
const connect = require('connect');
73+
const createDevServerMiddleware =
74+
require('../createDevServerMiddleware').default;
75+
testServer = await startTestServer();
76+
const {middleware, websocketEndpoints} = createDevServerMiddleware({
77+
serverBaseUrl: testServer.baseUrl,
78+
projectRoot: '/project',
79+
watchFolders: ['/project'],
80+
logger: {warn: () => {}, error: () => {}},
81+
});
82+
testServer.server.on('request', connect().use(middleware));
83+
testServer.mount('/message', websocketEndpoints['/message']);
84+
});
85+
86+
afterEach(() => testServer.close());
87+
88+
test('reloads the connected apps on /reload', async () => {
89+
const app = await testServer.connect('/message');
90+
91+
const resp = await fetch(testServer.baseUrl.origin + '/reload');
92+
93+
expect(await resp.text()).toBe('OK');
94+
expect(await app.next()).toEqual({version: 2, method: 'reload'});
95+
});
96+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import type {TestServer} from './SocketTestUtils';
12+
13+
import createEventsSocket from '../createEventsSocket';
14+
import {startTestServer, waitUntil} from './SocketTestUtils';
15+
16+
jest.useRealTimers();
17+
18+
const logger = {warn: jest.fn(), error: jest.fn()};
19+
const broadcast = jest.fn<[string, ?{[string]: unknown}], void>();
20+
21+
let testServer: TestServer;
22+
let eventsSocket: ReturnType<typeof createEventsSocket>;
23+
24+
beforeEach(async () => {
25+
logger.warn.mockReset();
26+
logger.error.mockReset();
27+
broadcast.mockReset();
28+
testServer = await startTestServer();
29+
eventsSocket = createEventsSocket({
30+
logger,
31+
serverBaseUrl: testServer.baseUrl,
32+
broadcast,
33+
});
34+
testServer.mount('/events', eventsSocket.server);
35+
});
36+
37+
afterEach(() => testServer.close());
38+
39+
describe('/events', () => {
40+
test('sends a reported event to every client', async () => {
41+
const first = await testServer.connect('/events');
42+
const second = await testServer.connect('/events');
43+
44+
eventsSocket.reportEvent({type: 'bundle_build_started', id: 'a'});
45+
46+
const expected = {type: 'bundle_build_started', id: 'a'};
47+
expect(await first.next()).toEqual(expected);
48+
expect(await second.next()).toEqual(expected);
49+
});
50+
51+
test('formats the error of an event', async () => {
52+
const client = await testServer.connect('/events');
53+
54+
eventsSocket.reportEvent({
55+
type: 'bundling_error',
56+
error: new Error('boom'),
57+
});
58+
59+
expect(await client.next()).toEqual({
60+
type: 'bundling_error',
61+
error: expect.stringContaining('boom'),
62+
});
63+
});
64+
65+
test('logs an event that cannot be serialized', async () => {
66+
const client = await testServer.connect('/events');
67+
const circular: {[string]: unknown} = {};
68+
circular.self = circular;
69+
70+
eventsSocket.reportEvent(circular);
71+
eventsSocket.reportEvent({type: 'after'});
72+
73+
expect(await client.next()).toEqual({type: 'after'});
74+
expect(logger.error).toHaveBeenCalledWith(
75+
expect.stringContaining('Failed to serialize'),
76+
);
77+
});
78+
79+
test('forwards a command to the connected apps', async () => {
80+
const client = await testServer.connect('/events');
81+
82+
client.send({
83+
version: 2,
84+
type: 'command',
85+
command: 'reload',
86+
params: {a: 1},
87+
});
88+
89+
await waitUntil(() =>
90+
expect(broadcast).toHaveBeenCalledWith('reload', {a: 1}),
91+
);
92+
});
93+
94+
test.each([
95+
['a message that is not JSON', 'not json', 'Failed to parse the message'],
96+
[
97+
'a message with another protocol version',
98+
'{"version":1,"type":"command","command":"reload"}',
99+
'wrong protocol version: 1',
100+
],
101+
])('logs and ignores %s', async (_, raw, logged) => {
102+
const client = await testServer.connect('/events');
103+
104+
client.sendRaw(raw);
105+
client.send({version: 2, type: 'command', command: 'devMenu'});
106+
await waitUntil(() => expect(broadcast).toHaveBeenCalledTimes(1));
107+
108+
expect(broadcast).toHaveBeenCalledWith('devMenu', undefined);
109+
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(logged));
110+
});
111+
112+
test.each([
113+
['no origin', undefined],
114+
['the dev server origin', 'SERVER'],
115+
['a file origin', 'file://'],
116+
])('accepts a client with %s', async (_, origin) => {
117+
const client = await testServer.connect('/events', {
118+
origin: origin === 'SERVER' ? testServer.baseUrl.origin : origin,
119+
});
120+
121+
eventsSocket.reportEvent({type: 'x'});
122+
123+
expect(await client.next()).toEqual({type: 'x'});
124+
});
125+
126+
test('refuses a client with a foreign origin', async () => {
127+
await expect(
128+
testServer.connect('/events', {origin: 'https://example.com'}),
129+
).rejects.toThrow('HTTP 401');
130+
});
131+
});

0 commit comments

Comments
 (0)