Compare commits
4 commits
df1a19209c
...
f4258fb35f
Author | SHA1 | Date | |
---|---|---|---|
|
f4258fb35f | ||
|
cf70e11fe8 | ||
|
f3b3611365 | ||
|
0a62f2a0f3 |
15 changed files with 330 additions and 270 deletions
|
@ -1,4 +1,4 @@
|
|||
# If the client is being served by the server itself, set to TRUE.
|
||||
# If you're running locally in dev mode, set to FALSE and update the SERVER variable to point to the server URL.
|
||||
VITE_STATIC='true'
|
||||
# If the client is being served by the server itself, set to 1 (ONE).
|
||||
# If you're running locally in dev mode, set to 0 (ZERO) and update the SERVER variable to point to the server URL.
|
||||
VITE_STATIC='1'
|
||||
VITE_SERVER='http://localhost:3000'
|
||||
|
|
|
@ -1,44 +1,41 @@
|
|||
<template>
|
||||
<div>
|
||||
<select v-model="selectedPartyLine" class="select select-bordered w-full max-w-xs">
|
||||
<option v-if="!partyLines?.length" disabled selected value="">No party lines available</option>
|
||||
<option v-if="partyLines?.length" disabled selected value="">Select a party line</option>
|
||||
<option v-for="partyLine in partyLines" :key="partyLine" :value="partyLine">{{
|
||||
partyLine
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn btn-outline btn-error" @click="deletePartyLine" :disabled="!selectedPartyLine.length">Delete Party Line</button>
|
||||
|
||||
<button class="btn btn-outline btn-info" @click="fetchPartyLines">Refresh List</button>
|
||||
<button class="btn btn-outline btn-info" @click="partyLineStore.fetchPartyLines">Refresh List</button>
|
||||
|
||||
<div class="collapse bg-base-200" v-for="partyLine in partyLines" :key="partyLine.lastEvent">
|
||||
<input type="radio" name="my-accordion-1"/>
|
||||
<div class="collapse-title text-xl font-medium">{{ partyLine.name }}</div>
|
||||
<div class="collapse-content">
|
||||
<p>Last Event: {{ partyLine.lastEvent }}</p>
|
||||
<p>Last Event: {{ new Date(partyLine.lastActivity).toLocaleString("en-GB") }}</p>
|
||||
<br/>
|
||||
Clients:
|
||||
<ul>
|
||||
<li v-for="client in partyLine.clients" :key="client.clientId">
|
||||
Client ID: {{ client.clientId }} - IP Address: {{ client.ipAddress }}
|
||||
</li>
|
||||
</ul>
|
||||
<br/>
|
||||
<button class="btn btn-outline btn-error" @click="deletePartyLine(partyLine.name)">Delete
|
||||
Party Line
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { usePartyLineStore } from '../stores/partyLine';
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const partyLineStore = usePartyLineStore();
|
||||
const { partyLines } = storeToRefs(partyLineStore);
|
||||
|
||||
const selectedPartyLine = ref('');
|
||||
|
||||
const fetchPartyLines = async () => {
|
||||
await partyLineStore.fetchPartyLines();
|
||||
};
|
||||
|
||||
// Fetch party lines when component is mounted
|
||||
onMounted(async () => {
|
||||
await fetchPartyLines();
|
||||
await partyLineStore.fetchPartyLines();
|
||||
});
|
||||
|
||||
const deletePartyLine = async () => {
|
||||
if (selectedPartyLine.value) {
|
||||
await partyLineStore.deletePartyLine(selectedPartyLine.value);
|
||||
selectedPartyLine.value = ''; // Clear selection after deletion
|
||||
await fetchPartyLines(); // Refresh the party lines after deletion
|
||||
}
|
||||
const deletePartyLine = async (partyLine: string) => {
|
||||
await partyLineStore.deletePartyLine(partyLine);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export const useGetServer = (): string => {
|
||||
if (Boolean(import.meta.env.VITE_STATIC)) {
|
||||
if (Number(import.meta.env.VITE_STATIC)) {
|
||||
return window.location.origin;
|
||||
} else {
|
||||
return import.meta.env.VITE_SERVER;
|
||||
|
|
|
@ -2,13 +2,15 @@ import { defineStore } from 'pinia';
|
|||
import { ref } from 'vue';
|
||||
import { useGetServer } from "@/composables/useGetServer.ts";
|
||||
import { useSound } from '@vueuse/sound';
|
||||
import notification from '../../public/notification.mp3';
|
||||
import notification from '/notification.mp3';
|
||||
import type { PartyLine } from "@/types/partyLine.ts";
|
||||
|
||||
export const usePartyLineStore = defineStore('partyLine', () => {
|
||||
|
||||
const partyLines = ref<PartyLine[]>([]);
|
||||
const partyLine = ref('');
|
||||
const rumor = ref('');
|
||||
const eventSource = ref<EventSource | null>(null);
|
||||
const partyLines = ref<string[]>([]);
|
||||
const partyLineDeletedFlag = ref(false);
|
||||
const { play } = useSound(notification);
|
||||
|
||||
|
@ -62,8 +64,7 @@ export const usePartyLineStore = defineStore('partyLine', () => {
|
|||
const fetchPartyLines = async () => {
|
||||
try {
|
||||
const response = await fetch(useGetServer() + '/partyLines');
|
||||
const data = await response.json();
|
||||
partyLines.value = data.allPartyLines;
|
||||
partyLines.value = await response.json();
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching party lines:', error);
|
||||
}
|
||||
|
|
11
client/src/types/partyLine.ts
Normal file
11
client/src/types/partyLine.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export type PartyLine = {
|
||||
name: string;
|
||||
clients: Client[];
|
||||
lastActivity: number;
|
||||
lastEvent: string;
|
||||
}
|
||||
|
||||
export type Client = {
|
||||
clientId: string;
|
||||
ipAddress: string;
|
||||
}
|
|
@ -7,7 +7,7 @@ import CreatePartyLine from "@/components/CreatePartyLine.vue";
|
|||
<template>
|
||||
<div class="w-screen text-center grid grid-cols-1 grid-rows-3 gap-3">
|
||||
<CreatePartyLine/>
|
||||
<DeletePartyLines/>
|
||||
<SendRumor/>
|
||||
<DeletePartyLines/>
|
||||
</div>
|
||||
</template>
|
|
@ -1,5 +1,5 @@
|
|||
PORT='3000'
|
||||
CLIENT_URL='http://localhost:3000' // If you're serving the static content leave as is, if not point to port 5173
|
||||
CLIENT_URL='http://localhost:3000' # If you're serving the static content leave as is, if not point to port 5173
|
||||
MAX_PARTY_LINES='5'
|
||||
INITIAL_RUMOR='...nothing has been said, yet.'
|
||||
ENVIRONMENT='development'
|
98
server/src/routes/connectionRoutes.ts
Normal file
98
server/src/routes/connectionRoutes.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import { Router } from 'express';
|
||||
import { query, validationResult } from 'express-validator';
|
||||
import { partyLines } from '../stores/dataStore';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Client } from "../types/partyLine";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Route to verify if a party line exists before connecting
|
||||
router.get('/joinPartyLine', [
|
||||
query('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const currentPartyLine = req.query.partyLine;
|
||||
const partyLine = partyLines[currentPartyLine];
|
||||
if (!partyLine) {
|
||||
return res.status(404).send({ status: 'Party line not found' });
|
||||
}
|
||||
res.status(200).send({ status: 'Connection to party line authorized' });
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to join party line', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route to connect to party line and receive rumors
|
||||
router.get('/connectPartyLine', [
|
||||
query('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const connectedPartyLine = req.query.partyLine;
|
||||
const partyLine = partyLines[connectedPartyLine];
|
||||
if (!partyLine) {
|
||||
return res.status(404).send({ status: 'Party line not found' });
|
||||
}
|
||||
|
||||
// Set headers for Server-Sent Events (SSE)
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
const clientId = `${uuidv4()}`;
|
||||
const client: Client = { clientId, response: res, ipAddress: res.socket.remoteAddress };
|
||||
|
||||
// Add client to the party line
|
||||
partyLine.clients.push(client);
|
||||
partyLine.lastActivity = Date.now();
|
||||
|
||||
console.log(`JOIN: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`);
|
||||
|
||||
// Send the last event to the client
|
||||
res.write(`data: ${partyLine.lastEvent}\n\n`);
|
||||
|
||||
// Keep-alive mechanism to keep the connection open
|
||||
const keepAliveId = setInterval(() => {
|
||||
res.write(': keep-alive\n\n');
|
||||
}, 15000);
|
||||
|
||||
// Function to remove the client from the party line
|
||||
const removeClient = () => {
|
||||
clearInterval(keepAliveId);
|
||||
const index = partyLine.clients.findIndex((client: any) => client.clientId === clientId);
|
||||
if (index !== -1) {
|
||||
partyLine.clients.splice(index, 1);
|
||||
console.log(`REMOVE: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`);
|
||||
} else {
|
||||
console.log(`CLIENT_NOT_FOUND: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`);
|
||||
}
|
||||
try {
|
||||
res.write('event: close\ndata: finished\n\n');
|
||||
res.end();
|
||||
} catch (err) {
|
||||
console.error(`ERROR: Error sending close event for client ${clientId}:`, err);
|
||||
}
|
||||
console.log(`DISCONNECT: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`);
|
||||
};
|
||||
|
||||
// Handle client disconnection
|
||||
req.on('close', removeClient);
|
||||
req.on('error', (err: any) => {
|
||||
console.error(`ERROR: Error in client connection in party line ${connectedPartyLine}:`, err);
|
||||
removeClient();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to connect to party line', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
115
server/src/routes/managementRoutes.ts
Normal file
115
server/src/routes/managementRoutes.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { Router } from 'express';
|
||||
import { body, validationResult } from 'express-validator';
|
||||
import { MAX_PARTY_LINES, INITIAL_RUMOR } from '../stores/configStore';
|
||||
import { partyLines } from '../stores/dataStore';
|
||||
import { broadcast } from '../services/broadcastService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Route to get all party lines
|
||||
router.get('/partyLines', (_req: any, res: any) => {
|
||||
const allPartyLines = Object.keys(partyLines).map(key => ({
|
||||
name: key,
|
||||
...partyLines[key],
|
||||
clients: partyLines[key].clients.map(({ response, ...client }) => client)
|
||||
}));
|
||||
res.status(200).send(allPartyLines);
|
||||
});
|
||||
|
||||
// Route to create a new party line
|
||||
router.post('/createPartyLine', [
|
||||
body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const { partyLine: currentPartyLine } = req.body;
|
||||
if (Object.keys(partyLines).length >= MAX_PARTY_LINES) {
|
||||
return res.status(400).send({ status: 'Maximum number of party lines reached' });
|
||||
}
|
||||
if (partyLines[currentPartyLine]) {
|
||||
return res.status(400).send({ status: 'Party line already exists' });
|
||||
}
|
||||
|
||||
// Create a new party line
|
||||
partyLines[currentPartyLine] = {
|
||||
clients: [],
|
||||
lastEvent: INITIAL_RUMOR,
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
|
||||
console.log(`CREATE: { partyLine: ${currentPartyLine} }`);
|
||||
res.status(200).send({ status: 'Party line created', currentPartyLine });
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to create party line', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route to delete a party line
|
||||
router.delete('/deletePartyLine', [
|
||||
body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const { partyLine: currentPartyLine } = req.body;
|
||||
const partyLine = partyLines[currentPartyLine];
|
||||
if (!partyLine) {
|
||||
return res.status(404).send({ status: 'Party line not found' });
|
||||
}
|
||||
|
||||
// Broadcast deletion message to all clients
|
||||
broadcast(currentPartyLine, 'PARTY_LINE_DELETED');
|
||||
|
||||
// Disconnect all clients
|
||||
partyLine.clients.forEach((client: any) => {
|
||||
console.log(`DISCONNECTING: { partyLine: ${currentPartyLine}, clientId: ${client.clientId} }`);
|
||||
client.response.end();
|
||||
});
|
||||
|
||||
// Delete the party line
|
||||
delete partyLines[currentPartyLine];
|
||||
console.log(`DELETE: { partyLine: ${currentPartyLine} }`);
|
||||
res.status(200).send({ status: 'Party line deleted', currentPartyLine });
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to delete party line', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route to send an event to a party line
|
||||
router.post('/rumor', [
|
||||
body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name'),
|
||||
body('rumor').isString().trim().escape().notEmpty().withMessage('Invalid rumor')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const { partyLine: connectedPartyLine, rumor } = req.body;
|
||||
const partyLine = partyLines[connectedPartyLine];
|
||||
if (!partyLine) {
|
||||
return res.status(404).send({ status: 'Party line not found' });
|
||||
}
|
||||
|
||||
console.log(`RECEIVE: { partyLine: ${connectedPartyLine}, rumor: ${rumor} }`);
|
||||
|
||||
// Update the last event and broadcast it to all clients
|
||||
partyLine.lastEvent = rumor;
|
||||
partyLine.clients.forEach((client: any) => {
|
||||
client.response.write(`data: ${rumor}\n\n`);
|
||||
});
|
||||
res.status(200).send({ status: 'Rumor broadcast' });
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to send event', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
11
server/src/routes/navigationRoutes.ts
Normal file
11
server/src/routes/navigationRoutes.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Navigate to the admin route in the client
|
||||
router.get('/admin', (_req: any, res: any) => {
|
||||
res.sendFile(path.resolve(__dirname, '../../static', 'index.html'));
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -1,21 +1,21 @@
|
|||
import express from 'express';
|
||||
import { body, query, validationResult } from 'express-validator';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import cors from 'cors';
|
||||
import bodyParser from 'body-parser';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import dotenv from 'dotenv';
|
||||
import helmet from 'helmet';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config();
|
||||
import { partyLines } from './stores/dataStore';
|
||||
import { ENVIRONMENT, PORT, CLIENT_URL, DOCKER } from './stores/configStore';
|
||||
import { broadcast } from './services/broadcastService';
|
||||
import connectionRoutes from './routes/connectionRoutes';
|
||||
import managementRoutes from './routes/managementRoutes';
|
||||
import navigationRoutes from './routes/navigationRoutes';
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT);
|
||||
|
||||
// CORS configuration options
|
||||
const corsOptions = {
|
||||
origin: process.env.CLIENT_URL,
|
||||
origin: CLIENT_URL,
|
||||
optionsSuccessStatus: 200,
|
||||
methods: ['GET', 'POST', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
|
@ -38,7 +38,7 @@ app.use(limiter);
|
|||
app.use('/', express.static(path.join(__dirname, '../static')));
|
||||
|
||||
// Force HTTPS redirection and CSP in production
|
||||
if (process.env.ENVIRONMENT === 'production') {
|
||||
if (ENVIRONMENT === 'production') {
|
||||
app.use((req: any, res: any, next: any) => {
|
||||
if (req.headers['x-forwarded-proto'] !== 'https') {
|
||||
return res.redirect(`https://${req.headers.host}${req.url}`);
|
||||
|
@ -57,7 +57,7 @@ if (process.env.ENVIRONMENT === 'production') {
|
|||
})
|
||||
);
|
||||
} else {
|
||||
// Disable https preference for non-prod
|
||||
// Disable CSP for non-prod
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
|
@ -65,131 +65,17 @@ if (process.env.ENVIRONMENT === 'production') {
|
|||
);
|
||||
}
|
||||
|
||||
// Navigate to the admin route in the client
|
||||
app.get('/admin', (_req: any, res: any) => {
|
||||
res.sendFile(path.resolve(__dirname, '../static', 'index.html'));
|
||||
});
|
||||
// Routes
|
||||
app.use(navigationRoutes);
|
||||
app.use(managementRoutes);
|
||||
app.use(connectionRoutes);
|
||||
|
||||
// Maximum number of party lines allowed
|
||||
const MAX_PARTY_LINES = Number(process.env.MAX_PARTY_LINES);
|
||||
|
||||
// In-memory storage for party lines
|
||||
const partyLines = {};
|
||||
|
||||
// Function to broadcast a rumor to all clients at a party line
|
||||
const broadcast = (currentPartyLine: string, rumor: string) => {
|
||||
const partyLine = partyLines[currentPartyLine];
|
||||
if (partyLine) {
|
||||
console.log(`BROADCAST: { partyLine: ${currentPartyLine}, rumor: ${rumor} }`);
|
||||
partyLine.clients.forEach((client: any) => {
|
||||
if (client.response && typeof client.response.write === 'function') {
|
||||
try {
|
||||
client.response.write(`data: ${rumor}\n\n`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: Failed to write to client ${client.clientId}`, err);
|
||||
}
|
||||
} else {
|
||||
console.error(`ERROR: Response object is invalid for client ${client.clientId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Route to verify if a party line exists before connecting
|
||||
app.get('/joinPartyLine', [
|
||||
query('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const currentPartyLine = req.query.partyLine;
|
||||
const partyLine = partyLines[currentPartyLine];
|
||||
if (!partyLine) {
|
||||
return res.status(404).send({ status: 'Party line not found' });
|
||||
}
|
||||
res.status(200).send({ status: 'Connection to party line authorized' });
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to join party line', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route to connect to party line and receive rumors
|
||||
app.get('/connectPartyLine', [
|
||||
query('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const connectedPartyLine = req.query.partyLine;
|
||||
const partyLine = partyLines[connectedPartyLine];
|
||||
if (!partyLine) {
|
||||
return res.status(404).send({ status: 'Party line not found' });
|
||||
}
|
||||
|
||||
// Set headers for Server-Sent Events (SSE)
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
const clientId = `${uuidv4()}`;
|
||||
const client = { clientId, response: res };
|
||||
|
||||
// Add client to the party line
|
||||
partyLine.clients.push(client);
|
||||
partyLine.lastActivity = Date.now();
|
||||
|
||||
console.log(`JOIN: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`);
|
||||
|
||||
// Send the last event to the client
|
||||
res.write(`data: ${partyLine.lastEvent}\n\n`);
|
||||
|
||||
// Keep-alive mechanism to keep the connection open
|
||||
const keepAliveId = setInterval(() => {
|
||||
res.write(': keep-alive\n\n');
|
||||
}, 15000);
|
||||
|
||||
// Function to remove the client from the party line
|
||||
const removeClient = () => {
|
||||
clearInterval(keepAliveId);
|
||||
const index = partyLine.clients.findIndex((client: any) => client.clientId === clientId);
|
||||
if (index !== -1) {
|
||||
partyLine.clients.splice(index, 1);
|
||||
console.log(`REMOVE: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`);
|
||||
} else {
|
||||
console.log(`CLIENT_NOT_FOUND: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`);
|
||||
}
|
||||
try {
|
||||
res.write('event: close\ndata: finished\n\n');
|
||||
res.end();
|
||||
} catch (err) {
|
||||
console.error(`ERROR: Error sending close event for client ${clientId}:`, err);
|
||||
}
|
||||
console.log(`DISCONNECT: { partyLine: ${connectedPartyLine}, clientId: ${clientId} }`);
|
||||
};
|
||||
|
||||
// Handle client disconnection
|
||||
req.on('close', removeClient);
|
||||
req.on('error', (err: any) => {
|
||||
console.error(`ERROR: Error in client connection in party line ${connectedPartyLine}:`, err);
|
||||
removeClient();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to connect to party line', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Periodically resend the last event and clean up inactive party lines
|
||||
// Periodically resend the last rumor and clean up inactive party lines
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
Object.keys(partyLines).forEach((currentPartyLine) => {
|
||||
const partyLine = partyLines[currentPartyLine];
|
||||
if (partyLine.lastEvent) {
|
||||
if (partyLine.lastEvent && partyLine.clients.length > 0) {
|
||||
broadcast(currentPartyLine, partyLine.lastEvent);
|
||||
}
|
||||
if (now - partyLine.lastActivity > 3600000) { // 1 hour in milliseconds
|
||||
|
@ -199,113 +85,11 @@ setInterval(() => {
|
|||
});
|
||||
}, 30000);
|
||||
|
||||
// Route to create a new party line
|
||||
app.post('/createPartyLine', [
|
||||
body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const { partyLine: currentPartyLine } = req.body;
|
||||
if (Object.keys(partyLines).length >= MAX_PARTY_LINES) {
|
||||
return res.status(400).send({ status: 'Maximum number of party lines reached' });
|
||||
}
|
||||
if (partyLines[currentPartyLine]) {
|
||||
return res.status(400).send({ status: 'Party line already exists' });
|
||||
}
|
||||
|
||||
// Create a new party line
|
||||
partyLines[currentPartyLine] = {
|
||||
clients: [],
|
||||
lastEvent: process.env.INITIAL_RUMOR,
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
|
||||
console.log(`CREATE: { partyLine: ${currentPartyLine} }`);
|
||||
res.status(200).send({ status: 'Party line created', currentPartyLine });
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to create party line', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route to send an event to a party line
|
||||
app.post('/rumor', [
|
||||
body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name'),
|
||||
body('rumor').isString().trim().escape().notEmpty().withMessage('Invalid rumor')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const { partyLine: connectedPartyLine, rumor } = req.body;
|
||||
const partyLine = partyLines[connectedPartyLine];
|
||||
if (!partyLine) {
|
||||
return res.status(404).send({ status: 'Party line not found' });
|
||||
}
|
||||
|
||||
console.log(`RECEIVE: { partyLine: ${connectedPartyLine}, rumor: ${rumor} }`);
|
||||
|
||||
// Update the last event and broadcast it to all clients
|
||||
partyLine.lastEvent = rumor;
|
||||
partyLine.clients.forEach((client: any) => {
|
||||
client.response.write(`data: ${rumor}\n\n`);
|
||||
});
|
||||
res.status(200).send({ status: 'Rumor broadcast' });
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to send event', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Route to get all party lines
|
||||
app.get('/partyLines', (_req: any, res: any) => {
|
||||
const allPartyLines = Object.keys(partyLines);
|
||||
res.status(200).send({ allPartyLines });
|
||||
});
|
||||
|
||||
// Route to delete a party line
|
||||
app.delete('/deletePartyLine', [
|
||||
body('partyLine').isString().trim().escape().notEmpty().withMessage('Invalid party line name')
|
||||
], (req: any, res: any) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const { partyLine: currentPartyLine } = req.body;
|
||||
const partyLine = partyLines[currentPartyLine];
|
||||
if (!partyLine) {
|
||||
return res.status(404).send({ status: 'Party line not found' });
|
||||
}
|
||||
|
||||
// Broadcast deletion message to all clients
|
||||
broadcast(currentPartyLine, 'PARTY_LINE_DELETED');
|
||||
|
||||
// Disconnect all clients
|
||||
partyLine.clients.forEach((client: any) => {
|
||||
console.log(`DISCONNECTING: { partyLine: ${currentPartyLine}, clientId: ${client.clientId} }`);
|
||||
client.response.end();
|
||||
});
|
||||
|
||||
// Delete the party line
|
||||
delete partyLines[currentPartyLine];
|
||||
console.log(`DELETE: { partyLine: ${currentPartyLine} }`);
|
||||
res.status(200).send({ status: 'Party line deleted', currentPartyLine });
|
||||
} catch (error) {
|
||||
console.error('ERROR: Failed to delete party line', error);
|
||||
res.status(500).send({ status: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Rumor Party Line`);
|
||||
console.log(`Internally running on http://localhost:${PORT} in ${process.env.ENVIRONMENT} mode.`);
|
||||
if (process.env.DOCKER) {
|
||||
console.log(`Internally running on http://localhost:${PORT} in ${ENVIRONMENT} mode.`);
|
||||
if (DOCKER) {
|
||||
console.log(`You're running on Docker!\n↳ Check your external port in the compose file to avoid confusion!`);
|
||||
}
|
||||
});
|
20
server/src/services/broadcastService.ts
Normal file
20
server/src/services/broadcastService.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { partyLines } from '../stores/dataStore';
|
||||
|
||||
// Function to broadcast a rumor to all clients at a party line
|
||||
export const broadcast = (currentPartyLine: string, rumor: string) => {
|
||||
const partyLine = partyLines[currentPartyLine];
|
||||
if (partyLine) {
|
||||
console.log(`BROADCAST: { partyLine: ${currentPartyLine}, rumor: ${rumor} }`);
|
||||
partyLine.clients.forEach((client: any) => {
|
||||
if (client.response && typeof client.response.write === 'function') {
|
||||
try {
|
||||
client.response.write(`data: ${rumor}\n\n`);
|
||||
} catch (err) {
|
||||
console.error(`ERROR: Failed to write to client ${client.clientId}`, err);
|
||||
}
|
||||
} else {
|
||||
console.error(`ERROR: Response object is invalid for client ${client.clientId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
9
server/src/stores/configStore.ts
Normal file
9
server/src/stores/configStore.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
export const ENVIRONMENT = process.env.ENVIRONMENT || 'development';
|
||||
export const PORT = Number(process.env.PORT) || 3000;
|
||||
export const CLIENT_URL = process.env.CLIENT_URL || 'http://localhost:3000';
|
||||
export const MAX_PARTY_LINES = Number(process.env.MAX_PARTY_LINES) || 1;
|
||||
export const INITIAL_RUMOR = process.env.INITIAL_RUMOR || 'Lorem Ipsum.';
|
||||
export const DOCKER = Boolean(process.env.DOCKER);
|
3
server/src/stores/dataStore.ts
Normal file
3
server/src/stores/dataStore.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { PartyLine } from "../types/partyLine";
|
||||
|
||||
export const partyLines: PartyLine[] = [];
|
11
server/src/types/partyLine.ts
Normal file
11
server/src/types/partyLine.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export type PartyLine = {
|
||||
clients: Client[];
|
||||
lastActivity: number;
|
||||
lastEvent: string;
|
||||
}
|
||||
|
||||
export type Client = {
|
||||
clientId: string;
|
||||
response: any;
|
||||
ipAddress: string;
|
||||
}
|
Loading…
Add table
Reference in a new issue