From 023b8acb8906390aa56599b570493822b14e6cfd Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Thu, 30 Oct 2025 08:09:15 +0100 Subject: [PATCH] feat: prototype done --- app/assets/css/tailwind.css | 3 +++ app/pages/index.vue | 12 +++++++--- app/pages/stream.vue | 16 ++++++++++--- nuxt.config.ts | 2 +- package.json | 1 + pnpm-lock.yaml | 33 +++++++++++++++++++++++++++ server/cron/clean.ts | 43 +++++++++++++++++++++++++++++++++++ server/routes/ws/signaling.ts | 12 +++++++--- 8 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 server/cron/clean.ts diff --git a/app/assets/css/tailwind.css b/app/assets/css/tailwind.css index 560a2a4..5609026 100644 --- a/app/assets/css/tailwind.css +++ b/app/assets/css/tailwind.css @@ -120,4 +120,7 @@ body { @apply bg-background text-foreground; } + h1 { + @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; + } } \ No newline at end of file diff --git a/app/pages/index.vue b/app/pages/index.vue index b829004..cf4a451 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -8,7 +8,10 @@ const viewerStore = useViewerStore() const { code: codeRef } = storeToRefs(viewerStore) const { send } = useWebSocket('ws://localhost:3000/ws/signaling', { autoReconnect: true, - //heartbeat: true, + heartbeat: { + message: JSON.stringify({ event: 'ping' }), + interval: 15000, + }, onMessage: async (ws, ev) => { const message = JSON.parse(ev.data) if (message.event === 'joined') { @@ -18,7 +21,9 @@ const { send } = useWebSocket('ws://localhost:3000/ws/signaling', { const peerConnection = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, ], + iceCandidatePoolSize: 10 }); viewerStore.setPeerConnection(peerConnection); @@ -76,13 +81,14 @@ watch(codeRef, (newCode) => { \ No newline at end of file diff --git a/app/pages/stream.vue b/app/pages/stream.vue index da126e2..a69297e 100644 --- a/app/pages/stream.vue +++ b/app/pages/stream.vue @@ -9,6 +9,10 @@ const localStream = ref(null); const { send } = useWebSocket('ws://localhost:3000/ws/signaling', { autoReconnect: true, + heartbeat: { + message: JSON.stringify({ event: 'ping' }), + interval: 15000, + }, onMessage: async (ws, ev) => { const message = JSON.parse(ev.data) @@ -18,7 +22,11 @@ const { send } = useWebSocket('ws://localhost:3000/ws/signaling', { if (message.event === 'viewer-joined') { const peerConnection = new RTCPeerConnection({ - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + ], + iceCandidatePoolSize: 10 }); streamerStore.addPeerConnection(message.viewerId, peerConnection) @@ -85,9 +93,11 @@ async function startScreenShare() { diff --git a/nuxt.config.ts b/nuxt.config.ts index 3771dcb..ec65fd4 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -10,7 +10,7 @@ export default defineNuxtConfig({ tailwindcss(), ], }, - modules: ['shadcn-nuxt', '@nuxtjs/color-mode', '@pinia/nuxt'], + modules: ['shadcn-nuxt', '@nuxtjs/color-mode', '@pinia/nuxt', 'nuxt-cron'], colorMode: { classSuffix: '' }, diff --git a/package.json b/package.json index 2dcbb82..7b427d6 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nuxtjs/color-mode": "^3.5.2", "@types/node": "^24.9.2", "nuxi": "^3.29.3", + "nuxt-cron": "^1.8.0", "typescript": "^5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea8eee2..9b70426 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: nuxi: specifier: ^3.29.3 version: 3.29.3 + nuxt-cron: + specifier: ^1.8.0 + version: 1.8.0(magicast@0.5.0) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1298,6 +1301,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/node@24.9.2': resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} @@ -1703,6 +1709,9 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} + cron@3.5.0: + resolution: {integrity: sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A==} + croner@9.1.0: resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==} engines: {node: '>=18.0'} @@ -2382,6 +2391,10 @@ packages: peerDependencies: vue: '>=3.0.1' + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + magic-regexp@0.10.0: resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} @@ -2553,6 +2566,9 @@ packages: engines: {node: ^16.10.0 || >=18.0.0} hasBin: true + nuxt-cron@1.8.0: + resolution: {integrity: sha512-XgNdounfdXNHOVlYTFDhc28yerwBP7+MP4Tk8GmC6GkxWo3nCWZ/pmqpan+tCmVVhUn6ltpe5KSCChWgl5P3kg==} + nuxt@4.2.0: resolution: {integrity: sha512-4qzf2Ymf07dMMj50TZdNZgMqCdzDch8NY3NO2ClucUaIvvsr6wd9+JrDpI1CckSTHwqU37/dIPFpvIQZoeHoYA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4851,6 +4867,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/luxon@3.4.2': {} + '@types/node@24.9.2': dependencies: undici-types: 7.16.0 @@ -5343,6 +5361,11 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.7.0 + cron@3.5.0: + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.5.0 + croner@9.1.0: {} cross-spawn@7.0.6: @@ -5987,6 +6010,8 @@ snapshots: dependencies: vue: 3.5.22(typescript@5.9.3) + luxon@3.5.0: {} + magic-regexp@0.10.0: dependencies: estree-walker: 3.0.3 @@ -6220,6 +6245,14 @@ snapshots: nuxi@3.29.3: {} + nuxt-cron@1.8.0(magicast@0.5.0): + dependencies: + '@nuxt/kit': 3.20.0(magicast@0.5.0) + cron: 3.5.0 + fast-glob: 3.3.3 + transitivePeerDependencies: + - magicast + nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@24.9.2)(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(rollup@4.52.5)(terser@5.44.0)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1): dependencies: '@dxup/nuxt': 0.2.0(magicast@0.5.0) diff --git a/server/cron/clean.ts b/server/cron/clean.ts new file mode 100644 index 0000000..9fad569 --- /dev/null +++ b/server/cron/clean.ts @@ -0,0 +1,43 @@ +import { defineCronHandler } from '#nuxt/cron' +import Redis from 'ioredis'; + +const IS_DEV = process.env.NODE_ENV === 'development'; + +export default defineCronHandler('everySecond', async () => { + const client = new Redis(process.env.REDIS_URL!); + + const activePeers = await client.hgetall('peers'); + const now = Date.now(); + const inactivePeerIds: string[] = []; + + // ident inactive peers + for (const [peerId, timestamp] of Object.entries(activePeers)) { + if (now - parseInt(timestamp) > 30000) { + inactivePeerIds.push(peerId); + } + } + + if (inactivePeerIds.length === 0) { + await client.quit(); + return; + } + + IS_DEV && console.log('[cron] cleaning up inactive peers:', inactivePeerIds); + + // delete rooms where broadcaster inactive + const roomKeys = await client.keys('room:*'); + for (const key of roomKeys) { + const roomData = await client.get(key); + if (!roomData) continue; + + const room = JSON.parse(roomData); + if (inactivePeerIds.includes(room.broadcaster)) { + await client.del(key); + IS_DEV && console.log(`[cron] deleted room ${key}`); + } + } + + // remove inactive peers + await client.hdel('peers', ...inactivePeerIds); + await client.quit(); +}) \ No newline at end of file diff --git a/server/routes/ws/signaling.ts b/server/routes/ws/signaling.ts index e9b9f82..f7c526d 100644 --- a/server/routes/ws/signaling.ts +++ b/server/routes/ws/signaling.ts @@ -24,16 +24,23 @@ async function getAllRoomIds() { } export default defineWebSocketHandler({ - open(peer) { + async open(peer) { activePeers.set(peer.id, peer); + await client.hset('peers', peer.id, Date.now().toString()); console.log('[ws] peer connected', peer.id); }, async message(peer, message) { + await client.hset('peers', peer.id, Date.now().toString()); + // TODO: proper typing const msg = message.json() as any; console.log("[ws] message", peer.id, msg); + if (msg.event === 'ping') { + peer.send(JSON.stringify({ event: 'pong' })); + return; + } if (msg.event === 'create-room') { const roomId = generateRoomId(); await saveRoom(roomId, { broadcaster: peer.id, viewers: [] }); @@ -44,9 +51,7 @@ export default defineWebSocketHandler({ if (room) { room.viewers.push(peer.id); await saveRoom(msg.roomId, room); - // Notify viewer they joined peer.send(JSON.stringify({ event: 'joined', roomId: msg.roomId })); - // Notify broadcaster const broadcasterPeer = activePeers.get(room.broadcaster); if (broadcasterPeer) { broadcasterPeer.send(JSON.stringify({ event: 'viewer-joined', viewerId: peer.id })); @@ -90,6 +95,7 @@ export default defineWebSocketHandler({ async close(peer, event) { console.log("[ws] close", peer.id, event); activePeers.delete(peer.id); + await client.hdel('peers', peer.id); const roomKeys = await getAllRoomIds(); for (const key of roomKeys) {