testapp: Improve testsapp using Typescript

This commit is contained in:
Carlos Santos 2025-05-07 17:32:40 +02:00
parent d4f3b48082
commit 347d9472e0
28 changed files with 4621 additions and 262 deletions

View File

@ -1,2 +1,10 @@
# 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

24
testapp/.eslintrc Normal file
View File

@ -0,0 +1,24 @@
{
"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"
}
}

27
testapp/.eslintrc.json Normal file
View File

@ -0,0 +1,27 @@
{
"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"
}
}

10
testapp/.prettierrc Normal file
View File

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

View File

@ -1,38 +0,0 @@
export const joinRoom = async (req, res) => {
try {
const { participantRole, roomUrl } = req.body;
if (!roomUrl) {
throw new Error('Room URL is required.');
}
res.render('videoRoom', {
roomUrl,
participantRole,
isModerator: participantRole === 'moderator',
});
} catch (error) {
console.error('Error joining room:', error);
res
.status(400)
.json({ message: 'Error joining room', error: error.message });
}
};
export const handleWebhook = async (req, res, io) => {
try {
// Log event
console.log(`Webhook received:`, req.body);
io.emit('webhookEvent', req.body);
res.status(200).send('Webhook received');
} catch (error) {
console.error('Error handling webhook:', error);
res
.status(400)
.json({ message: 'Error handling webhook', error: error.message });
}
}

View File

@ -1,42 +0,0 @@
export const renderHomePage = async (req, res) => {
try {
// Get all OpenVidu Meet Rooms
const response = await fetch(`${process.env.OPENVIDU_MEET_URL}/rooms`)
const rooms = await response.json()
if (!response.ok) {
throw new Error('Failed to fetch rooms')
}
res.render('home', { rooms })
} catch (error) {
console.error('Error fetching rooms:', error)
res.render('home', { rooms: [], error: 'Failed to load rooms' })
}
}
export const createRoom = async (req, res) => {
try {
// Extract values from request body
const { roomIdPrefix, autoDeletionDate } = req.body
// Request to create a new room
const response = await fetch(`${process.env.OPENVIDU_MEET_URL}/rooms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
roomIdPrefix,
autoDeletionDate: new Date(autoDeletionDate).getTime()
})
})
// Handle response
const data = await response.json()
if (!response.ok) throw new Error(data.message || 'Room creation failed')
renderHomePage(req, res)
} catch (error) {
console.error('Room creation error:', error)
res.status(500).json({ message: 'Error creating a room', error: error.message })
}
}

6
testapp/nodemon.json Normal file
View File

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

3557
testapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,27 @@
{
"name": "meet-nodejs-sample",
"name": "meet-testapp",
"version": "1.0.0",
"description": "",
"main": "server.js",
"main": "dist/server.js",
"type": "module",
"scripts": {
"start": "node server.js"
"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"
},
"dependencies": {
"cors": "2.8.5",
@ -13,5 +29,23 @@
"express": "^4.21.2",
"mustache-express": "^1.3.2",
"socket.io": "^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/socket.io": "^3.0.1",
"@typescript-eslint/eslint-plugin": "^8.32.0",
"@typescript-eslint/parser": "^8.32.0",
"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",
"typescript": "^5.8.3"
}
}

117
testapp/public/js/home.ts Normal file
View File

@ -0,0 +1,117 @@
import { formatDate as formatDateUtil, getRelativeTimeString as getRelativeTimeStringUtil } from '../../utils/common.js';
/**
* Interface for room data
*/
interface Room {
roomId: string;
roomIdPrefix: string;
moderatorRoomUrl: string;
publisherRoomUrl: string;
viewerRoomUrl: string;
createdAt: number;
autoDeletionDate: number;
}
document.addEventListener('DOMContentLoaded', () => {
setupRoomElements();
setupFormValidation();
});
/**
* Set up dynamic room elements and event handlers
*/
function setupRoomElements(): void {
// Format dates in the room cards
document.querySelectorAll('[data-timestamp]').forEach((element) => {
const timestamp = parseInt(element.getAttribute('data-timestamp') || '0', 10);
if (timestamp > 0) {
element.textContent = formatDateUtil(timestamp);
}
});
// Set relative time for room creation
document.querySelectorAll('[data-created-at]').forEach((element) => {
const timestamp = parseInt(element.getAttribute('data-created-at') || '0', 10);
if (timestamp > 0) {
element.textContent = getRelativeTimeStringUtil(timestamp);
}
});
// Initialize copy URL buttons
document.querySelectorAll('.copy-url-btn').forEach((button) => {
button.addEventListener('click', (event) => handleCopyUrl(event as MouseEvent));
});
}
/**
* Handle click event for copying URLs
*/
function handleCopyUrl(event: MouseEvent): void {
const button = event.currentTarget as HTMLButtonElement;
const url = button.getAttribute('data-url');
if (url) {
navigator.clipboard.writeText(url).then(
() => {
// Show success feedback
const originalText = button.textContent || '';
button.textContent = 'Copied!';
button.classList.add('copied');
// Reset button text after 2 seconds
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('copied');
}, 2000);
},
(err) => {
console.error('Failed to copy text: ', err);
}
);
}
}
/**
* Set up form validation for the create room form
*/
function setupFormValidation(): void {
const createRoomForm = document.getElementById('create-room-form') as HTMLFormElement | null;
if (createRoomForm) {
createRoomForm.addEventListener('submit', (event) => {
const roomIdPrefixInput = document.getElementById('roomIdPrefix') as HTMLInputElement;
const autoDeletionDateInput = document.getElementById('autoDeletionDate') as HTMLInputElement;
// Basic validation
if (!roomIdPrefixInput.value.trim()) {
event.preventDefault();
alert('Please enter a room prefix');
return;
}
if (!autoDeletionDateInput.value) {
event.preventDefault();
alert('Please select an auto deletion date');
return;
}
// Set minimum date to today
const today = new Date();
today.setHours(0, 0, 0, 0);
const selectedDate = new Date(autoDeletionDateInput.value);
if (selectedDate < today) {
event.preventDefault();
alert('Auto deletion date must be today or in the future');
}
});
}
// Set minimum date for the date picker to today
const autoDeletionDateInput = document.getElementById('autoDeletionDate') as HTMLInputElement;
if (autoDeletionDateInput) {
const today = new Date().toISOString().split('T')[0];
autoDeletionDateInput.min = today;
}
}

View File

@ -1,51 +0,0 @@
document.getElementById('end-meeting-btn')?.addEventListener('click', () => {
const meet = document.querySelector('openvidu-meet');
meet.endMeeting();
});
document.getElementById('leave-room-btn')?.addEventListener('click', () => {
const meet = document.querySelector('openvidu-meet');
meet.leaveRoom();
});
document.getElementById('toggle-chat-btn')?.addEventListener('click', () => {
const meet = document.querySelector('openvidu-meet');
meet.toggleChat();
});
document.addEventListener('DOMContentLoaded', () => {
const socket = io();
// Listen for webhook events from the server
socket.on('webhookEvent', (payload) => {
console.log('Webhook event received:', payload);
addWebhookToLog(payload);
});
console.log('DOM loaded');
const meet = document.querySelector('openvidu-meet');
// Event listener for when the local participant joined the room
meet.addEventListener('join', (event) => {
addEventToLog(event.type, `${JSON.stringify(event.detail)}`);
});
// Event listener for when the local participant left the room
meet.addEventListener('left', (event) => {
addEventToLog(event.type, `${JSON.stringify(event.detail)}`);
});
});
function addEventToLog(eventType, eventMessage) {
const eventsList = document.getElementById('events-list');
const li = document.createElement('li');
li.textContent = `[ ${eventType} ] : ${eventMessage}`;
eventsList.appendChild(li);
}
function addWebhookToLog(payload) {
const webhookLogList = document.getElementById('webhook-log-list');
const li = document.createElement('li');
li.textContent = `[ ${payload.event} ] : ${JSON.stringify(payload)}`;
webhookLogList.appendChild(li);
}

View File

@ -0,0 +1,161 @@
/**
* Event types that can be emitted by the OpenVidu-Meet web component
*/
type MeetEvent = 'JOIN' | 'LEFT' | 'ERROR';
/**
* Interface for OpenVidu-Meet component events
*/
interface MeetEventDetail {
participantId?: string;
participantName?: string;
roomId?: string;
timestamp: number;
error?: {
message: string;
code: string;
};
}
/**
* Interface for OpenVidu-Meet web component
*/
interface OpenViduMeetElement extends HTMLElement {
endMeeting(): void;
leaveRoom(): void;
toggleChat(): void;
}
/**
* Interface for webhook events
*/
interface WebhookEvent {
event: string;
[key: string]: any;
}
// Set up event listeners when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Set up button click handlers
setupButtonHandlers();
// Initialize socket.io connection
const socket = io();
// Get API key from the data attribute
const apiKey = document.getElementById('meeting-container')?.dataset.apiKey || '';
// Store API key in a variable that can be used for fetch requests
window.meetApiKey = apiKey;
// Listen for webhook events from the server
socket.on('webhookEvent', (payload: WebhookEvent) => {
console.log('Webhook event received:', payload);
addWebhookToLog(payload);
});
console.log('DOM loaded');
const meet = document.querySelector('openvidu-meet') as OpenViduMeetElement;
if (meet) {
// Event listener for when the local participant joined the room
meet.addEventListener('JOIN', ((event: CustomEvent<MeetEventDetail>) => {
addEventToLog('JOIN', JSON.stringify(event.detail));
}) as EventListener);
// Event listener for when the local participant left the room
meet.addEventListener('LEFT', ((event: CustomEvent<MeetEventDetail>) => {
addEventToLog('LEFT', JSON.stringify(event.detail));
}) as EventListener);
// Error event listener
meet.addEventListener('ERROR', ((event: CustomEvent<MeetEventDetail>) => {
addEventToLog('ERROR', JSON.stringify(event.detail));
}) as EventListener);
}
});
/**
* Set up button click handlers
*/
function setupButtonHandlers(): void {
const meet = document.querySelector('openvidu-meet') as OpenViduMeetElement;
// End meeting button click handler
document.getElementById('end-meeting-btn')?.addEventListener('click', () => {
if (meet) {
meet.endMeeting();
}
});
// Leave room button click handler
document.getElementById('leave-room-btn')?.addEventListener('click', () => {
if (meet) {
meet.leaveRoom();
}
});
// Toggle chat button click handler
document.getElementById('toggle-chat-btn')?.addEventListener('click', () => {
if (meet) {
meet.toggleChat();
}
});
}
/**
* Add a component event to the events log
*/
function addEventToLog(eventType: MeetEvent, eventMessage: string): void {
const eventsList = document.getElementById('events-list');
if (eventsList) {
const li = document.createElement('li');
li.textContent = `[ ${eventType} ] : ${eventMessage}`;
eventsList.appendChild(li);
}
}
/**
* Add a webhook event to the webhooks log
*/
function addWebhookToLog(payload: WebhookEvent): void {
const webhookLogList = document.getElementById('webhook-log-list');
if (webhookLogList) {
const li = document.createElement('li');
li.textContent = `[ ${payload.event} ] : ${JSON.stringify(payload)}`;
webhookLogList.appendChild(li);
}
}
/**
* Utility function to make API requests with the API key
*/
function fetchWithApiKey(url: string, options: RequestInit = {}): Promise<Response> {
// Ensure headers object exists
const headers = new Headers(options.headers || {});
// Add API key to headers
headers.append('X-API-KEY', window.meetApiKey);
headers.append('Accept', 'application/json');
// Handle JSON request body
if (options.body && typeof options.body !== 'string') {
headers.append('Content-Type', 'application/json');
options.body = JSON.stringify(options.body);
}
// Create new options object with merged headers
const fetchOptions: RequestInit = {
...options,
headers
};
return fetch(url, fetchOptions);
}
// Add meetApiKey to window interface
declare global {
interface Window {
meetApiKey: string;
}
}

View File

@ -53,7 +53,7 @@
<!-- Main Content Section -->
<div id="main-content" class="d-flex flex-column">
<!-- Web Component Section -->
<div id="meeting-container">
<div id="meeting-container" data-api-key="{{ apiKey }}">
<openvidu-meet
room-url="{{ roomUrl }}"
participant-name="{{ participantName }}"

View File

@ -0,0 +1,71 @@
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

@ -0,0 +1,58 @@
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,44 +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 { joinRoom, handleWebhook } from './controllers/ videoRoomController.js';
import { createRoom, renderHomePage } from './controllers/homeController.js';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
dotenv.config();
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.engine('mustache', mustacheExpress());
app.set('view engine', 'mustache');
app.set('views', path.join(__dirname, '/views'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(bodyParser.urlencoded({ extended: true }));
// Middlewares
app.use(cors());
// Routes
app.get('/', renderHomePage);
app.post('/room', createRoom);
app.post('/join-room', joinRoom);
app.post('/webhook', (req, res) => {
handleWebhook(req, res, io);
});
const PORT = process.env.PORT || 5080;
server.listen(PORT, () => {
console.log(`Listening on http://localhost:${PORT}`);
});

76
testapp/server.ts Normal file
View File

@ -0,0 +1,76 @@
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

@ -0,0 +1,56 @@
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();
try {
logger.info('Rendering home page');
const rooms = await roomService.getAllRooms();
res.render('home', { rooms });
} catch (error) {
logger.error('Error fetching rooms:', error);
res.render('home', { rooms: [], error: 'Failed to load rooms' });
}
};
/**
* Creates a new room based on form data
*/
export const createRoom = async (req: Request, res: Response): Promise<void> => {
const roomService = RoomService.getInstance();
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);
} catch (error) {
logger.error('Room creation error:', error);
res.status(500).json({ message: 'Error creating a room', error: (error as Error).message });
}
};

View File

@ -0,0 +1,73 @@
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 });
}
};

View File

@ -0,0 +1,50 @@
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,102 @@
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;
}
}
}

17
testapp/src/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
/**
* 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

@ -0,0 +1,65 @@
/**
* 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

@ -0,0 +1,50 @@
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.');

View File

@ -0,0 +1,83 @@
/**
* 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);
}
}
}

22
testapp/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"sourceMap": true,
"outDir": "./dist",
"strict": 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/*"]
}
},
"include": ["src/**/*", "server.ts"],
"exclude": ["node_modules", "dist"]
}

View File

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