testapp: Updated testapp

This commit is contained in:
Carlos Santos 2025-05-09 16:40:38 +02:00
parent a0964aa4ca
commit 654d082e9c
33 changed files with 2895 additions and 5423 deletions

View File

@ -1,10 +1,3 @@
# API Settings
OPENVIDU_MEET_URL=http://localhost:6080/meet/api/v1
API_KEY=meet-api-key
# Server Settings
PORT=5080
LOG_LEVEL=INFO
# Environment
NODE_ENV=development

View File

@ -1,24 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
"prettier/prettier": "error",
"no-console": "warn",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "warn"
},
"env": {
"browser": true,
"node": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
}
}

View File

@ -1,27 +0,0 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"prettier"
],
"rules": {
"prettier/prettier": "error",
"no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-module-boundary-types": "off"
}
}

View File

@ -1,10 +0,0 @@
{
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}

0
testapp/README.md Normal file
View File

View File

@ -1,6 +0,0 @@
{
"watch": ["src", "server.ts", ".env"],
"ignore": ["src/public"],
"ext": "ts,js,json",
"exec": "ts-node --esm server.ts"
}

3220
testapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +1,36 @@
{
"name": "meet-testapp",
"name": "testapp",
"version": "1.0.0",
"description": "",
"main": "dist/server.js",
"type": "module",
"main": "index.js",
"scripts": {
"start": "npm run build && node dist/server.js",
"start:dev": "NODE_ENV=development nodemon --watch \"**/*.ts\" --exec \"node --loader ts-node/esm server.ts\"",
"start:prod": "NODE_ENV=production node dist/server.js",
"dev": "concurrently \"npm:watch-*\"",
"watch-ts": "tsc --watch",
"watch-node": "nodemon --watch dist dist/server.js",
"watch-client": "nodemon --watch src/public/js --ext ts --exec node src/utils/build-client.js",
"build": "npm run clean && npm run build-server && npm run build-client && npm run copy-assets",
"build:dev": "NODE_ENV=development npm run build",
"build:prod": "NODE_ENV=production npm run build",
"build-server": "tsc",
"build-client": "node scripts/build-client.js",
"copy-assets": "node scripts/copy-assets.js",
"lint": "eslint . --ext .ts,.js",
"format": "prettier --write \"**/*.{ts,js}\"",
"clean": "rimraf dist",
"test": "jest"
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"build:watch": "tsc --watch",
"start": "node dist/index.js",
"dev:server": "ts-node-dev --respawn --watch src,public/ts src/index.ts",
"dev": "concurrently \"npm:watch:client\" \"npm run dev:server\" --kill-others",
"build:client": "tsc -p tsconfig.client.json",
"watch:client": "tsc -p tsconfig.client.json --watch"
},
"keywords": [],
"author": "",
"license": "Apache-2.0",
"description": "",
"dependencies": {
"cors": "2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"mustache-express": "^1.3.2",
"socket.io": "^4.8.1"
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
"@types/mustache-express": "^1.2.5",
"@types/node": "^22.15.15",
"@types/node": "^22.15.17",
"@types/socket.io": "^3.0.1",
"@typescript-eslint/eslint-plugin": "^8.32.0",
"@typescript-eslint/parser": "^8.32.0",
"@types/socket.io-client": "^1.4.36",
"concurrently": "^9.1.2",
"eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.3",
"eslint-plugin-prettier": "^5.4.0",
"nodemon": "^3.1.10",
"prettier": "^3.5.3",
"rimraf": "^6.0.1",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.8.3"
}
}

View File

@ -22,9 +22,9 @@ html {
/* Left Sidebar Panel */
#control-panel {
width: 300px;
width: 350px;
background: white;
padding: 10px 15px;
padding: 5px 5px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
@ -48,6 +48,7 @@ html {
border-radius: 8px;
color: white;
list-style: none;
margin: 0;
}
#control-panel h3 {
@ -64,10 +65,11 @@ html {
}
#control-panel .log-list li {
margin-bottom: 5px;
margin-bottom: 10px;
padding: 5px;
background-color: #3a3a3a;
background-color: #474747;
border-radius: 4px;
overflow-wrap: break-word;
}
/* Main Content Section (Web Component) */
@ -88,3 +90,16 @@ html {
justify-content: center;
align-items: center;
}
pre code {
white-space: pre-wrap;
}
.section {
padding: 0px;
}
.section .title {
font-size: 1rem;
margin: .5rem;
}

View File

@ -0,0 +1,162 @@
const socket = (window as any).io();
/**
* Add a component event to the events log
*/
const addEventToLog = (eventType: string, eventMessage: string): void => {
const eventsList = document.getElementById('events-list');
if (eventsList) {
const li = document.createElement('li');
li.textContent = `[ ${eventType} ] : ${eventMessage}`;
eventsList.appendChild(li);
}
};
const escapeHtml = (unsafe: string): string => {
return unsafe
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const listenWebhookServerEvents = () => {
socket.on('webhookEvent', (payload: any) => {
console.log('Webhook received:', payload);
const webhookLogList = document.getElementById('webhook-log-list');
if (webhookLogList) {
// Create unique IDs for this accordion item
const itemId = payload.creationDate;
const headerId = `header-${itemId}`;
const collapseId = `collapse-${itemId}`;
// Create accordion item container
const accordionItem = document.createElement('div');
accordionItem.className = 'accordion-item';
// Create header
const header = document.createElement('h2');
header.className = 'accordion-header';
header.id = headerId;
// Create header button
const button = document.createElement('button');
button.className = 'accordion-button';
button.type = 'button';
button.setAttribute('data-bs-toggle', 'collapse');
button.setAttribute('data-bs-target', `#${collapseId}`);
button.setAttribute('aria-expanded', 'true');
button.setAttribute('aria-controls', collapseId);
button.style.padding = '10px';
if (payload.event === 'meetingStarted') {
button.classList.add('bg-success');
}
if (payload.event === 'meetingEnded') {
button.classList.add('bg-danger');
}
if (payload.event.includes('recording')) {
button.classList.add('bg-warning');
}
// Format the header text with event name and timestamp
const date = new Date(payload.creationDate);
const formattedDate = date.toLocaleString('es-ES', {
// year: 'numeric',
// month: '2-digit',
// day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
button.innerHTML = `[${formattedDate}] <strong>${payload.event}</strong>`;
// Create collapsible content container
const collapseDiv = document.createElement('div');
collapseDiv.id = collapseId;
collapseDiv.className = 'accordion-collapse collapse';
collapseDiv.setAttribute('aria-labelledby', headerId);
collapseDiv.setAttribute('data-bs-parent', '#webhook-log-list');
// Create body content
const bodyDiv = document.createElement('div');
bodyDiv.className = 'accordion-body';
// Format JSON with syntax highlighting if possible
const formattedJson = JSON.stringify(payload, null, 2);
bodyDiv.innerHTML = `<pre class="mb-0"><code>${escapeHtml(
formattedJson
)}</code></pre>`;
// Assemble the components
header.appendChild(button);
collapseDiv.appendChild(bodyDiv);
accordionItem.appendChild(header);
accordionItem.appendChild(collapseDiv);
// Insert at the top of the list (latest events first)
if (webhookLogList.firstChild) {
webhookLogList.insertBefore(accordionItem, webhookLogList.firstChild);
} else {
webhookLogList.appendChild(accordionItem);
}
// Limit the number of items to prevent performance issues
const maxItems = 50;
while (webhookLogList.children.length > maxItems) {
webhookLogList.removeChild(webhookLogList.lastChild!);
}
}
});
};
// Listen to events from openvidu-meet
const listenWebComponentEvents = () => {
const meet = document.querySelector('openvidu-meet') as any;
if (!meet) {
console.error('openvidu-meet component not found');
alert('openvidu-meet component not found in the DOM');
return;
}
meet.on('JOIN', (event: CustomEvent<any>) => {
console.log('JOIN event received:', event);
addEventToLog('JOIN', JSON.stringify(event));
});
meet.on('LEFT', (event: CustomEvent<any>) => {
console.log('LEFT event received:', event);
addEventToLog('LEFT', JSON.stringify(event));
});
};
const setUpWebComponentCommands = () => {
const meet = document.querySelector('openvidu-meet') as any;
if (!meet) {
console.error('openvidu-meet component not found');
alert('openvidu-meet component not found in the DOM');
return;
}
// End meeting button click handler
document
.getElementById('end-meeting-btn')
?.addEventListener('click', () => meet.endMeeting());
// Leave room button click handler
document
.getElementById('leave-room-btn')
?.addEventListener('click', () => meet.leaveRoom());
// Toggle chat button click handler
document
.getElementById('toggle-chat-btn')
?.addEventListener('click', () => meet.toggleChat());
};
document.addEventListener('DOMContentLoaded', () => {
listenWebhookServerEvents();
listenWebComponentEvents();
setUpWebComponentCommands();
});

View File

@ -25,10 +25,11 @@
<ul class="list-group">
{{#rooms}}
<li
id="{{roomIdPrefix}}"
class="list-group-item d-flex justify-content-between align-items-center"
>
<span>{{ roomId }}</span>
<div class="dropdown">
<div class="dropdown-button">
<button
class="btn btn-primary btn-sm dropdown-toggle"
type="button"
@ -50,7 +51,7 @@
value="moderator"
/>
<button type="submit" class="dropdown-item">
<button type="submit" id="join-as-moderator" class="dropdown-item">
Moderator
</button>
</form>
@ -68,29 +69,11 @@
value="publisher"
/>
<button type="submit" class="dropdown-item">
<button type="submit" id="join-as-publisher" class="dropdown-item">
Publisher
</button>
</form>
</li>
<li>
<form action="/join-room" method="post">
<input
type="hidden"
name="roomUrl"
value="{{ viewerRoomUrl }}"
/>
<input
type="hidden"
name="participantRole"
value="viewer"
/>
<button type="submit" class="dropdown-item">
Viewer
</button>
</form>
</li>
</ul>
</div>
</li>
@ -130,11 +113,10 @@
name="autoDeletionDate"
id="expiration-date"
class="form-control"
required
/>
</div>
<button type="submit" class="btn btn-primary w-100">
<button type="submit" class="create-room-btn btn btn-primary w-100">
Create Room
</button>
</form>

View File

@ -18,10 +18,9 @@
<!-- Left Sidebar Panel -->
<div id="control-panel" class="d-flex flex-column">
<h3>{{ participantRole }}</h3>
<hr />
<!-- Commands -->
<div class="section">
<h5>Commands</h5>
<h5 class="title">Commands</h5>
{{#isModerator}}
<button id="end-meeting-btn" class="btn btn-danger">End Meeting</button>
{{/isModerator}}
@ -31,19 +30,17 @@
</button>
</div>
<hr />
<!-- Events -->
<div class="section">
<h5>Events</h5>
<h5 class="title">Events</h5>
<ul id="events-list" class="log-list">
<!-- Events will be added dynamically here -->
</ul>
</div>
<hr />
<!-- Webhooks Log -->
<div class="section">
<h5>Webhooks</h5>
<h5 class="title">Webhook</h5>
<ul id="webhook-log-list" class="log-list">
<!-- Webhooks will be added dynamically here -->
</ul>
@ -64,8 +61,7 @@
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="js/videoRoom.js"></script>
<script type="module" src="../js/webcomponent.js"></script>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>

View File

@ -1,71 +0,0 @@
import { exec } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Get directory name in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Source directory for client TypeScript files
const sourceDir = path.join(__dirname, '..', 'public', 'js');
// Destination directory for compiled JavaScript
const destDir = path.join(__dirname, '..', 'dist', 'testapp', 'public', 'js');
// Create destination directory if it doesn't exist
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// Get all TypeScript files in the source directory
const tsFiles = fs.readdirSync(sourceDir).filter(file => file.endsWith('.ts'));
// Compile each TypeScript file individually
console.log('Compiling client-side TypeScript files...');
// Create a much simpler compilation approach
tsFiles.forEach(file => {
const sourcePath = path.join(sourceDir, file);
const outputFile = file.replace('.ts', '.js');
const destPath = path.join(destDir, outputFile);
// Read the TypeScript file
const tsContent = fs.readFileSync(sourcePath, 'utf-8');
console.log(`Processing ${file}...`);
// Use esbuild to quickly transpile TypeScript to JavaScript
// For a simple application, we'll do some basic transforms ourselves
let jsContent = tsContent;
// Remove TypeScript type annotations and interfaces
jsContent = jsContent.replace(/^interface\s+\w+\s*\{[\s\S]*?\}/gm, '');
jsContent = jsContent.replace(/^type\s+\w+\s*=[\s\S]*?;/gm, '');
jsContent = jsContent.replace(/:\s*\w+(\[\])?(\s*\|\s*null)?(\s*\|\s*undefined)?/g, '');
jsContent = jsContent.replace(/<\w+(\[\])?>/g, '');
jsContent = jsContent.replace(/as\s+\w+(\[\])?/g, '');
// Save the JavaScript file
fs.writeFileSync(destPath, jsContent);
console.log(`Created ${outputFile}`);
});
// Copy utility files that client code might need
const commonUtilPath = path.join(__dirname, '..', 'utils', 'common.js');
const destCommonPath = path.join(destDir, 'common.js');
// Basic conversion of the common.ts file to JS
if (fs.existsSync(path.join(__dirname, '..', 'utils', 'common.ts'))) {
const tsContent = fs.readFileSync(path.join(__dirname, '..', 'utils', 'common.ts'), 'utf-8');
let jsContent = tsContent;
// Remove TypeScript type annotations
jsContent = jsContent.replace(/:\s*\w+(\[\])?(\s*\|\s*null)?(\s*\|\s*undefined)?/g, '');
jsContent = jsContent.replace(/<\w+(\[\])?>/g, '');
fs.writeFileSync(destCommonPath, jsContent);
console.log('Created common.js utility file');
}
console.log('Client-side files processed successfully');

View File

@ -1,58 +0,0 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Get directory name in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Define paths
const projectRoot = path.join(__dirname, '..');
const viewsDir = path.join(projectRoot, 'public', 'views');
const distViewsDir = path.join(projectRoot, 'dist', 'public', 'views');
const cssDir = path.join(projectRoot, 'public', 'css');
const distCssDir = path.join(projectRoot, 'dist', 'testapp', 'public', 'css');
// Create dist directories if they don't exist
function ensureDirectoryExists(directory) {
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
console.log(`Created directory: ${directory}`);
}
}
// Copy files from source to destination
function copyFiles(sourceDir, destDir, extension) {
ensureDirectoryExists(destDir);
// Check if source directory exists
if (!fs.existsSync(sourceDir)) {
console.log(`Source directory does not exist: ${sourceDir}`);
return;
}
const files = fs.readdirSync(sourceDir).filter(file => file.endsWith(extension));
files.forEach(file => {
const sourcePath = path.join(sourceDir, file);
const destPath = path.join(destDir, file);
fs.copyFileSync(sourcePath, destPath);
console.log(`Copied ${sourcePath} to ${destPath}`);
});
console.log(`Copied ${files.length} ${extension} files`);
}
console.log('Starting asset copy process...');
// Copy view templates
console.log('Copying Mustache templates...');
copyFiles(viewsDir, distViewsDir, '.mustache');
// Copy CSS files
console.log('Copying CSS files...');
copyFiles(cssDir, distCssDir, '.css');
console.log('All static assets have been copied to dist.');

View File

@ -1,76 +0,0 @@
import dotenv from 'dotenv';
import express from 'express';
import mustacheExpress from 'mustache-express';
import bodyParser from 'body-parser';
import cors from 'cors';
import path from 'path';
import http from 'http';
import { Server } from 'socket.io';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Import controllers
import { joinRoom, handleWebhook } from './src/controllers/videoRoomController.js';
import { createRoom, renderHomePage } from './src/controllers/homeController.js';
import { ConfigService } from './src/services/config.service.js';
import { Logger } from './src/utils/logger.js';
// Initialize logger
const logger = new Logger('Server');
// Get directory name in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Define project root (going up from the dist folder if we're running the compiled version)
const projectRoot =
process.env.NODE_ENV === 'production'
? path.join(__dirname, '..') // In production, dist/server.js is 1 level down
: __dirname; // In development, we're in the project root
// Initialize environment variables
dotenv.config();
logger.info('Environment variables loaded');
// Get configuration
const config = ConfigService.getInstance();
// Setup Express and HTTP server
const app = express();
const server = http.createServer(app);
const io = new Server(server);
// Configure view engine and middleware
app.engine('mustache', mustacheExpress());
app.set('view engine', 'mustache');
app.set('views', path.join(projectRoot, 'public/views'));
app.use(express.static(path.join(projectRoot, 'public')));
app.use(express.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(cors());
logger.debug('Express middleware configured');
logger.debug(`Static directory: ${path.join(projectRoot, 'public')}`);
logger.debug(`Views directory: ${path.join(projectRoot, 'public/views')}`);
// Define routes
app.get('/', renderHomePage);
app.post('/room', createRoom);
app.post('/join-room', joinRoom);
app.post('/webhook', (req, res) => {
handleWebhook(req, res, io);
});
// Socket.IO connection handler
io.on('connection', socket => {
logger.info(`New socket connection: ${socket.id}`);
socket.on('disconnect', () => {
logger.info(`Socket disconnected: ${socket.id}`);
});
});
// Start the server
const PORT = config.port;
server.listen(PORT, () => {
logger.info(`Server started successfully on http://localhost:${PORT}`);
});

View File

@ -1,56 +1,46 @@
import { Request, Response } from 'express';
import { RoomService } from '../services/room.service.js';
import { Logger } from '../utils/logger.js';
import { MeetRoomOptions } from '../../../typings/src/room.js';
// Initialize logger
const logger = new Logger('HomeController');
/**
* Renders the home page with available rooms
*/
export const renderHomePage = async (req: Request, res: Response): Promise<void> => {
const roomService = RoomService.getInstance();
import { getAllRooms, createRoom, deleteRoom } from '../services/roomService';
export const getHome = async (req: Request, res: Response) => {
try {
logger.info('Rendering home page');
const rooms = await roomService.getAllRooms();
res.render('home', { rooms });
const { rooms } = await getAllRooms();
//sort rooms by newest first
rooms.sort((a, b) => {
const dateA = new Date(a.creationDate);
const dateB = new Date(b.creationDate);
return dateB.getTime() - dateA.getTime();
});
console.log(`Rooms fetched: ${rooms.length}`);
res.render('index', { rooms });
} catch (error) {
logger.error('Error fetching rooms:', error);
res.render('home', { rooms: [], error: 'Failed to load rooms' });
console.error('Error fetching rooms:', error);
res.status(500).send('Internal Server Error');
return;
}
};
/**
* Creates a new room based on form data
*/
export const createRoom = async (req: Request, res: Response): Promise<void> => {
const roomService = RoomService.getInstance();
export const postCreateRoom = async (req: Request, res: Response) => {
try {
// Extract values from request body
const { roomIdPrefix, autoDeletionDate } = req.body;
logger.info(`Creating room with prefix: ${roomIdPrefix}`);
const roomData: MeetRoomOptions = {
roomIdPrefix,
autoDeletionDate: new Date(autoDeletionDate).getTime(),
};
const newRoom = await roomService.createRoom(roomData);
if (!newRoom) {
throw new Error('Room creation failed');
}
logger.info(`Room created successfully: ${newRoom.roomId}`);
// Redirect to home page showing the new room
await renderHomePage(req, res);
await createRoom({ roomIdPrefix, autoDeletionDate });
res.redirect('/');
} catch (error) {
logger.error('Room creation error:', error);
res.status(500).json({ message: 'Error creating a room', error: (error as Error).message });
console.error('Error creating room:', error);
res.status(500).send('Internal Server Error');
return;
}
};
export const postDeleteRoom = async (req: Request, res: Response) => {
try {
const { roomId } = req.body;
await deleteRoom(roomId);
res.redirect('/');
} catch (error) {
console.error('Error deleting room:', error);
res.status(500).send('Internal Server Error');
return;
}
};

View File

@ -0,0 +1,57 @@
import { Request, Response } from 'express';
import { Server as IOServer } from 'socket.io';
import { ParticipantRole } from '../../../typings/src/participant';
// @ts-ignore
import { MeetWebhookEvent } from '../../../typings/src/webhook.model';
interface JoinRoomRequest {
participantRole: ParticipantRole;
roomUrl: string;
participantName?: string;
}
export const joinRoom = (req: Request, res: Response) => {
try {
const {
participantRole,
roomUrl,
participantName = 'User',
} = req.body as JoinRoomRequest;
if (!roomUrl) {
throw new Error('Room URL is required.');
}
res.render('room', {
roomUrl,
participantRole,
participantName,
isModerator: participantRole === 'moderator',
});
} catch (error) {
console.error('Error joining room:', error);
res.status(500).send('Internal Server Error');
return;
}
};
export const handleWebhook = async (
req: Request,
res: Response,
io: IOServer
): Promise<void> => {
try {
const webhookEvent = req.body as MeetWebhookEvent;
// Log event details
console.info(`Webhook received: ${webhookEvent.event}`);
// Broadcast to all connected clients
io.emit('webhookEvent', webhookEvent);
res.status(200).send('Webhook received');
} catch (error) {
console.error('Error handling webhook:', error);
res.status(400).json({
message: 'Error handling webhook',
error: (error as Error).message,
});
}
};

View File

@ -1,73 +0,0 @@
import { Request, Response } from 'express';
import { Server } from 'socket.io';
import { ConfigService } from '../services/config.service.js';
import { Logger } from '../utils/logger.js';
import { MeetWebhookEvent } from '../../../typings/src/webhook.model.js';
import { ParticipantRole } from '../../../typings/src/participant.js';
// Initialize logger
const logger = new Logger('VideoRoomController');
/**
* Join Room request body interface
*/
interface JoinRoomRequest {
participantRole: ParticipantRole;
roomUrl: string;
participantName?: string;
}
/**
* Handles joining a room with the specified role
*/
export const joinRoom = async (req: Request, res: Response): Promise<void> => {
try {
const { participantRole, roomUrl, participantName = 'User' } = req.body as JoinRoomRequest;
if (!roomUrl) {
throw new Error('Room URL is required.');
}
logger.info(`Joining room as ${participantRole}: ${roomUrl}`);
const config = ConfigService.getInstance();
res.render('videoRoom', {
roomUrl,
participantRole,
participantName,
isModerator: participantRole === 'moderator',
apiKey: config.apiKey,
});
logger.info(`Successfully rendered video room for ${participantName}`);
} catch (error) {
logger.error('Error joining room:', error);
res.status(400).json({ message: 'Error joining room', error: (error as Error).message });
}
};
/**
* Handles incoming webhook events from OpenVidu Meet
*/
export const handleWebhook = async (req: Request, res: Response, io: Server): Promise<void> => {
try {
const webhookEvent = req.body as MeetWebhookEvent;
// Log event details
logger.info(`Webhook received: ${webhookEvent.event}`, {
type: webhookEvent.event,
timestamp: webhookEvent.creationDate,
data: webhookEvent.data,
});
// Broadcast to all connected clients
io.emit('webhookEvent', webhookEvent);
logger.debug('Event broadcast to all clients');
res.status(200).send('Webhook received');
} catch (error) {
logger.error('Error handling webhook:', error);
res.status(400).json({ message: 'Error handling webhook', error: (error as Error).message });
}
};

48
testapp/src/index.ts Normal file
View File

@ -0,0 +1,48 @@
import express from 'express';
import http from 'http';
import { Server as IOServer } from 'socket.io';
import path from 'path';
import {
getHome,
postCreateRoom,
postDeleteRoom,
} from './controllers/homeController';
import { handleWebhook, joinRoom } from './controllers/roomController';
import { configService } from './services/configService';
const app = express();
const server = http.createServer(app);
const io = new IOServer(server);
// View engine setup
app.engine('mustache', require('mustache-express')());
app.set('views', path.join(__dirname, '../public/views'));
app.set('view engine', 'mustache');
// Static assets
app.use(express.static(path.join(__dirname, '../public')));
// Parse URL-encoded bodies (for form submissions)
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// Routes
app.get('/', getHome);
app.get('/room', joinRoom);
app.post('/room', postCreateRoom);
app.post('/room/delete', postDeleteRoom);
app.post('/join-room', joinRoom);
app.post('/webhook', (req, res) => {
handleWebhook(req, res, io);
});
const PORT = configService.port;
server.listen(PORT, () => {
console.log('-----------------------------------------');
console.log(`Server running on port ${PORT}`);
console.log(`Meet API URL: ${configService.meetApiUrl}`);
console.log('-----------------------------------------');
console.log('');
console.log('Visit http://localhost:3000/ to access the app');
});

View File

@ -1,50 +0,0 @@
import { Logger } from '../utils/logger.js';
/**
* Service for handling configuration and environment variables
*/
export class ConfigService {
private static instance: ConfigService;
private logger: Logger;
// Environment variables with defaults
public readonly meetApiUrl: string;
public readonly apiKey: string;
public readonly port: number;
private constructor() {
this.logger = new Logger('ConfigService');
// Load environment variables with defaults
this.meetApiUrl = process.env.OPENVIDU_MEET_URL || 'http://localhost:6080/meet/api/v1';
this.apiKey = process.env.API_KEY || 'meet-api-key';
this.port = parseInt(process.env.PORT || '5080', 10);
this.logger.debug('Configuration loaded:', {
meetApiUrl: this.meetApiUrl,
port: this.port,
// Don't log sensitive data like API keys
});
}
/**
* Get singleton instance of ConfigService
*/
public static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService();
}
return ConfigService.instance;
}
/**
* Get API headers with authentication
*/
public getApiHeaders(): Record<string, string> {
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-API-KEY': this.apiKey
};
}
}

View File

@ -0,0 +1,17 @@
import dotenv from 'dotenv';
dotenv.config();
export class ConfigService {
public meetApiUrl: string;
public apiKey: string;
public port: number;
constructor() {
this.meetApiUrl = process.env.OPENVIDU_MEET_URL || 'http://localhost:6080/meet/api/v1';
this.apiKey = process.env.API_KEY || 'meet-api-key';
this.port = parseInt(process.env.PORT || '5080', 10);
}
}
export const configService = new ConfigService();

View File

@ -0,0 +1,4 @@
// src/services/index.ts
// Aquí puedes exportar servicios de negocio
export {};

View File

@ -1,102 +0,0 @@
import { ConfigService } from './config.service.js';
import { Logger } from '../utils/logger.js';
import { MeetRoom, MeetRoomOptions } from '../../../typings/src/room.js';
/**
* Service for handling Room-related API operations
*/
export class RoomService {
private static instance: RoomService;
private config: ConfigService;
private logger: Logger;
private constructor() {
this.config = ConfigService.getInstance();
this.logger = new Logger('RoomService');
}
/**
* Get singleton instance of RoomService
*/
public static getInstance(): RoomService {
if (!RoomService.instance) {
RoomService.instance = new RoomService();
}
return RoomService.instance;
}
/**
* Fetch all rooms from the API
*/
public async getAllRooms(): Promise<MeetRoom[]> {
try {
this.logger.info('Fetching all rooms from API');
const response = await fetch(`${this.config.meetApiUrl}/rooms`, {
headers: this.config.getApiHeaders(),
});
if (!response.ok) {
throw new Error(`Failed to fetch rooms: ${response.statusText}`);
}
const data: { pagination: any; rooms: MeetRoom[] } = await response.json();
this.logger.debug(`Retrieved ${data.rooms.length} rooms`);
return data.rooms;
} catch (error) {
this.logger.error('Error fetching rooms:', error);
return [];
}
}
/**
* Create a new room
*/
public async createRoom(roomData: MeetRoomOptions): Promise<MeetRoom | null> {
try {
this.logger.info(`Creating new room with prefix: ${roomData.roomIdPrefix}`);
const response = await fetch(`${this.config.meetApiUrl}/rooms`, {
method: 'POST',
headers: this.config.getApiHeaders(),
body: JSON.stringify(roomData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Room creation failed');
}
const newRoom: MeetRoom = await response.json();
this.logger.info(`Room created successfully: ${newRoom.roomId}`);
return newRoom;
} catch (error) {
this.logger.error('Error creating room:', error);
return null;
}
}
/**
* Get a specific room by ID
*/
public async getRoomById(roomId: string): Promise<MeetRoom | null> {
try {
this.logger.info(`Fetching room with id: ${roomId}`);
const response = await fetch(`${this.config.meetApiUrl}/rooms/${roomId}`, {
headers: this.config.getApiHeaders(),
});
if (!response.ok) {
if (response.status === 404) {
this.logger.warn(`Room not found: ${roomId}`);
return null;
}
throw new Error(`Failed to fetch room: ${response.statusText}`);
}
const room = await response.json();
return room;
} catch (error) {
this.logger.error(`Error fetching room ${roomId}:`, error);
return null;
}
}
}

View File

@ -0,0 +1,29 @@
import { get, post, del } from '../utils/http';
import { configService } from './configService';
// @ts-ignore
import { MeetRoom, MeetRoomOptions } from '../../../typings/src/room';
export async function getAllRooms(): Promise<{pagination:any, rooms: MeetRoom[]}> {
const url = `${configService.meetApiUrl}/rooms`;
return get<{pagination:any, rooms: MeetRoom[]}>(url, {
headers: { 'x-api-key': configService.apiKey },
});
}
export async function createRoom(roomData: MeetRoomOptions): Promise<MeetRoom> {
const url = `${configService.meetApiUrl}/rooms`;
// Default to 1 hour if autoDeletionDate is not provided
if(!roomData.autoDeletionDate) roomData.autoDeletionDate = new Date(Date.now() + 60 * 61 * 1000).getTime();
console.log('Creating room with options:', roomData);
return post<MeetRoom>(url, {
headers: { 'x-api-key': configService.apiKey },
body: roomData,
});
}
export async function deleteRoom(roomId: string): Promise<void> {
const url = `${configService.meetApiUrl}/rooms/${roomId}`;
await del<void>(url, {
headers: { 'x-api-key': configService.apiKey },
});
}

View File

@ -1,17 +0,0 @@
/**
* Global type declarations for browser libraries
*/
declare const io: () => SocketIOClient.Socket;
interface Window {
meetApiKey: string;
}
declare namespace SocketIOClient {
interface Socket {
on(event: string, callback: (data: any) => void): Socket;
emit(event: string, data: any): Socket;
disconnect(): void;
}
}

View File

View File

@ -1,65 +0,0 @@
/**
* Common utility functions used across the application
*/
/**
* Format a date to a human-readable string
* @param timestamp - Unix timestamp in milliseconds
*/
export function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString();
}
/**
* Generate a human-readable relative time string (e.g., "5 minutes ago")
* @param timestamp - Unix timestamp in milliseconds
*/
export function getRelativeTimeString(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days === 1 ? '' : 's'} ago`;
if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
if (minutes > 0) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
return `${seconds} second${seconds === 1 ? '' : 's'} ago`;
}
/**
* Convert a string to title case (e.g., "hello world" -> "Hello World")
* @param str - String to convert
*/
export function toTitleCase(str: string): string {
return str.replace(
/\w\S*/g,
txt => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
);
}
/**
* Safely parse JSON without throwing an exception
* @param jsonString - JSON string to parse
* @param fallback - Default value if parsing fails
*/
export function safeJsonParse<T>(jsonString: string, fallback: T): T {
try {
return JSON.parse(jsonString) as T;
} catch (e) {
console.warn('Failed to parse JSON:', e);
return fallback;
}
}
/**
* Truncate a string to a maximum length with ellipsis
* @param str - String to truncate
* @param maxLength - Maximum length
*/
export function truncateString(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.substring(0, maxLength - 3) + '...';
}

View File

@ -1,50 +0,0 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Get directory name in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Define paths
const projectRoot = path.join(__dirname, '..', '..');
const viewsDir = path.join(projectRoot, 'views');
const distViewsDir = path.join(projectRoot, 'dist', 'views');
const cssDir = path.join(projectRoot, 'public', 'css');
const distCssDir = path.join(projectRoot, 'dist', 'public', 'css');
// Create dist directories if they don't exist
function ensureDirectoryExists(directory: string): void {
if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
console.log(`Created directory: ${directory}`);
}
}
// Copy files from source to destination
function copyFiles(sourceDir: string, destDir: string, extension: string): void {
ensureDirectoryExists(destDir);
const files = fs.readdirSync(sourceDir).filter(file => file.endsWith(extension));
files.forEach(file => {
const sourcePath = path.join(sourceDir, file);
const destPath = path.join(destDir, file);
fs.copyFileSync(sourcePath, destPath);
console.log(`Copied ${sourcePath} to ${destPath}`);
});
console.log(`Copied ${files.length} ${extension} files`);
}
// Copy view templates
console.log('Copying Mustache templates...');
copyFiles(viewsDir, distViewsDir, '.mustache');
// Copy CSS files
console.log('Copying CSS files...');
copyFiles(cssDir, distCssDir, '.css');
console.log('All static assets have been copied to dist.');

42
testapp/src/utils/http.ts Normal file
View File

@ -0,0 +1,42 @@
export interface RequestOptions {
headers?: Record<string, string>;
queryParams?: Record<string, string>;
body?: any;
}
async function buildUrl(url: string, queryParams?: Record<string, string>): Promise<string> {
if (!queryParams) return url;
const params = new URLSearchParams(queryParams as Record<string, string>).toString();
return `${url}?${params}`;
}
async function request<T>(method: string, url: string, options: RequestOptions = {}): Promise<T> {
const fullUrl = await buildUrl(url, options.queryParams);
const fetchOptions: RequestInit = {
method,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
body: options.body ? JSON.stringify(options.body) : undefined
};
const response = await fetch(fullUrl, fetchOptions);
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text}`);
}
return response.json() as Promise<T>;
}
export function get<T>(url: string, options?: Omit<RequestOptions, 'body'>): Promise<T> {
return request<T>('GET', url, options || {});
}
export function post<T>(url: string, options?: Omit<RequestOptions, 'body'> & { body: any }): Promise<T> {
return request<T>('POST', url, options as RequestOptions);
}
export function put<T>(url: string, options?: Omit<RequestOptions, 'body'> & { body: any }): Promise<T> {
return request<T>('PUT', url, options as RequestOptions);
}
export function del<T>(url: string, options?: Omit<RequestOptions, 'body'>): Promise<T> {
return request<T>('DELETE', url, options || {});
}

View File

@ -1,83 +0,0 @@
/**
* Logger utility for consistent logging across the application
*/
/**
* Log levels for the application
*/
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3
}
/**
* Current log level for the application
* Can be set via environment variable LOG_LEVEL
*/
const currentLogLevel = (() => {
const envLevel = process.env.LOG_LEVEL?.toUpperCase();
switch (envLevel) {
case 'DEBUG': return LogLevel.DEBUG;
case 'INFO': return LogLevel.INFO;
case 'WARN': return LogLevel.WARN;
case 'ERROR': return LogLevel.ERROR;
default: return LogLevel.INFO; // Default level
}
})();
/**
* Logger class for consistent log formatting
*/
export class Logger {
private name: string;
constructor(name: string) {
this.name = name;
}
/**
* Format a log message with timestamp and context
*/
private formatMessage(message: string): string {
const timestamp = new Date().toISOString();
return `[${timestamp}] [${this.name}] ${message}`;
}
/**
* Log debug level message
*/
debug(message: string, ...args: any[]): void {
if (currentLogLevel <= LogLevel.DEBUG) {
console.debug(this.formatMessage(message), ...args);
}
}
/**
* Log info level message
*/
info(message: string, ...args: any[]): void {
if (currentLogLevel <= LogLevel.INFO) {
console.info(this.formatMessage(message), ...args);
}
}
/**
* Log warning level message
*/
warn(message: string, ...args: any[]): void {
if (currentLogLevel <= LogLevel.WARN) {
console.warn(this.formatMessage(message), ...args);
}
}
/**
* Log error level message
*/
error(message: string, ...args: any[]): void {
if (currentLogLevel <= LogLevel.ERROR) {
console.error(this.formatMessage(message), ...args);
}
}
}

View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES6",
"module": "ES6",
"moduleResolution": "node",
"rootDir": "public/ts",
"outDir": "public/js",
"strict": true,
"esModuleInterop": true
},
"include": ["public/ts/**/*"]
}

View File

@ -1,22 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"sourceMap": true,
"outDir": "./dist",
"target": "ES6",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowJs": true, // Allow JavaScript files to be compiled
"checkJs": true, // Type check JavaScript files
"lib": ["DOM", "ES2020", "DOM.Iterable"],
"paths": {
"@/*": ["./src/*"]
}
"moduleResolution": "node"
},
"include": ["src/**/*", "server.ts"],
"exclude": ["node_modules", "dist"]
"include": ["src/**/*"]
}

View File

@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
}