feat: prototype done

This commit is contained in:
2025-10-30 08:09:15 +01:00
parent f58f4533a2
commit 023b8acb89
8 changed files with 112 additions and 10 deletions

View File

@@ -120,4 +120,7 @@
body {
@apply bg-background text-foreground;
}
h1 {
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
}
}

View File

@@ -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) => {
</script>
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<h1>helium</h1>
<p>effortless screensharing powered by webrtc</p>
<p>code is {{ viewerStore.code }}</p>
<app-code-input />
<video ref="videofeedRef" autoplay playsinline style="width: 100%; max-width: 1200px; background: black;"></video>
<video ref="videofeedRef" autoplay playsinline controls style="width: 100%; max-width: 1200px; background: black;"></video>
<NuxtLink to="/stream"><Button>host instead?</Button></NuxtLink>
</div>
</template>

View File

@@ -9,6 +9,10 @@ const localStream = ref<MediaStream|null>(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() {
</script>
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<Button @click="startScreenShare">
screenshare
screenshare
</Button>
<p>Your stream code: {{ streamerStore.code }}</p>
<p v-if="streamerStore.code" class="font-mono">{{ streamerStore.code }}</p>
<video ref="videofeedRef" autoplay playsinline muted></video>
</div>
</template>

View File

@@ -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: ''
},

View File

@@ -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"
}
}

33
pnpm-lock.yaml generated
View File

@@ -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)

43
server/cron/clean.ts Normal file
View File

@@ -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();
})

View File

@@ -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) {