Configure board deployment and backend proxy
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY realtime-server.mjs ./
|
||||
|
||||
USER node
|
||||
EXPOSE 8787
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8787/healthz | grep -q '"status":"ok"'
|
||||
|
||||
CMD ["node", "realtime-server.mjs"]
|
||||
Generated
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "wc-board-realtime",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wc-board-realtime",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "wc-board-realtime",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node realtime-server.mjs",
|
||||
"start": "node realtime-server.mjs",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
function isFocusEvent(value) {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
value.type === 'FOCUS_NAME' &&
|
||||
typeof value.cloudId === 'string' &&
|
||||
typeof value.name === 'string' &&
|
||||
typeof value.messageId === 'string' &&
|
||||
typeof value.sentAt === 'number' &&
|
||||
(value.targetIndex === undefined ||
|
||||
(typeof value.targetIndex === 'number' &&
|
||||
Number.isInteger(value.targetIndex) &&
|
||||
value.targetIndex >= 0))
|
||||
);
|
||||
}
|
||||
|
||||
export function createRealtimeServer(port = Number(process.env.REALTIME_PORT || 8787)) {
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === '/healthz') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ status: 'ok' }));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { 'content-type': 'text/plain' });
|
||||
response.end('Not found');
|
||||
});
|
||||
const realtime = new WebSocketServer({
|
||||
server,
|
||||
maxPayload: 16 * 1024,
|
||||
});
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const socket of realtime.clients) {
|
||||
if (socket.isAlive === false) {
|
||||
socket.terminate();
|
||||
continue;
|
||||
}
|
||||
|
||||
socket.isAlive = false;
|
||||
socket.ping();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
heartbeat.unref();
|
||||
|
||||
realtime.on('connection', (socket) => {
|
||||
socket.isAlive = true;
|
||||
socket.on('pong', () => {
|
||||
socket.isAlive = true;
|
||||
});
|
||||
|
||||
socket.on('message', (data) => {
|
||||
let value;
|
||||
|
||||
try {
|
||||
value = JSON.parse(data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFocusEvent(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify(value);
|
||||
|
||||
for (const client of realtime.clients) {
|
||||
if (client !== socket && client.readyState === client.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
realtime.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
const port = Number(process.env.REALTIME_PORT || 8787);
|
||||
createRealtimeServer(port).listen(port, () => {
|
||||
console.log(`Realtime WebSocket server listening on port ${port}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRealtimeServer } from './realtime-server.mjs';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
test('broadcasts focus events to other connected clients', async () => {
|
||||
const server = createRealtimeServer(0);
|
||||
await new Promise((resolve) => server.listen(resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
try {
|
||||
const screen = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
const controller = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
|
||||
await Promise.all([
|
||||
new Promise((resolve) => screen.once('open', resolve)),
|
||||
new Promise((resolve) => controller.once('open', resolve)),
|
||||
]);
|
||||
|
||||
const received = new Promise((resolve) => {
|
||||
screen.once('message', (data) => resolve(JSON.parse(data)));
|
||||
});
|
||||
|
||||
controller.send(JSON.stringify({
|
||||
type: 'FOCUS_NAME',
|
||||
cloudId: 'demo',
|
||||
name: 'Sophia',
|
||||
messageId: 'focus-1',
|
||||
sentAt: 1,
|
||||
}));
|
||||
|
||||
assert.deepEqual(await received, {
|
||||
type: 'FOCUS_NAME',
|
||||
cloudId: 'demo',
|
||||
name: 'Sophia',
|
||||
messageId: 'focus-1',
|
||||
sentAt: 1,
|
||||
});
|
||||
|
||||
screen.close();
|
||||
controller.close();
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user