diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts deleted file mode 100644 index 415c310..0000000 --- a/src/app/api/answer/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { channelRepo } from '../../../channel' - -export async function POST(request: NextRequest): Promise { - const { slug, offerID, answer } = await request.json() - - if (!slug) { - return NextResponse.json({ error: 'Slug is required' }, { status: 400 }) - } - - if (!offerID) { - return NextResponse.json({ error: 'Offer ID is required' }, { status: 400 }) - } - - if (!answer) { - return NextResponse.json({ error: 'Answer is required' }, { status: 400 }) - } - - const success = await channelRepo.answer(slug, offerID, answer) - return NextResponse.json({ success }) -} - -export async function GET(request: NextRequest): Promise { - const { searchParams } = new URL(request.url) - const slug = searchParams.get('slug') - const offerID = searchParams.get('offerID') - - if (!slug) { - return NextResponse.json({ error: 'Slug is required' }, { status: 400 }) - } - - if (!offerID) { - return NextResponse.json({ error: 'Offer ID is required' }, { status: 400 }) - } - - const answer = await channelRepo.fetchAnswer(slug, offerID) - return NextResponse.json({ answer }) -} diff --git a/src/app/api/create/route.ts b/src/app/api/create/route.ts index 7ebfc9b..830fcf2 100644 --- a/src/app/api/create/route.ts +++ b/src/app/api/create/route.ts @@ -1,7 +1,13 @@ import { NextResponse } from 'next/server' import { Channel, channelRepo } from '../../../channel' -export async function POST(): Promise { - const channel: Channel = await channelRepo.createChannel() +export async function POST(request: Request): Promise { + const { uploaderPeerID } = await request.json() + + if (!uploaderPeerID) { + return NextResponse.json({ error: 'Uploader peer ID is required' }, { status: 400 }) + } + + const channel: Channel = await channelRepo.createChannel(uploaderPeerID) return NextResponse.json(channel) } diff --git a/src/app/api/offer/route.ts b/src/app/api/offer/route.ts deleted file mode 100644 index aafd05e..0000000 --- a/src/app/api/offer/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { channelRepo } from '../../../channel' - -export async function POST(request: NextRequest): Promise { - const { slug, offer } = await request.json() - - if (!slug) { - return NextResponse.json({ error: 'Slug is required' }, { status: 400 }) - } - - if (!offer) { - return NextResponse.json({ error: 'Offer is required' }, { status: 400 }) - } - - const offerID = await channelRepo.offer(slug, offer) - return NextResponse.json({ offerID }) -} diff --git a/src/app/api/renew/route.ts b/src/app/api/renew/route.ts index bdb18cf..a15c2b0 100644 --- a/src/app/api/renew/route.ts +++ b/src/app/api/renew/route.ts @@ -12,6 +12,6 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ error: 'Secret is required' }, { status: 400 }) } - const offers = await channelRepo.renewChannel(slug, secret) - return NextResponse.json({ success: true, offers }) + const success = await channelRepo.renewChannel(slug, secret) + return NextResponse.json({ success }) } diff --git a/src/app/download/[...slug]/page.tsx b/src/app/download/[...slug]/page.tsx index d8818f5..e7f6e9e 100644 --- a/src/app/download/[...slug]/page.tsx +++ b/src/app/download/[...slug]/page.tsx @@ -30,7 +30,7 @@ export default async function DownloadPage({ - + ) diff --git a/src/channel.ts b/src/channel.ts index 50d3d8f..239ae62 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -9,35 +9,28 @@ export type Channel = { secret?: string longSlug: string shortSlug: string + uploaderPeerID: string } const ChannelSchema = z.object({ secret: z.string().optional(), longSlug: z.string(), shortSlug: z.string(), + uploaderPeerID: z.string(), }) export interface ChannelRepo { - createChannel(ttl?: number): Promise + createChannel( + uploaderPeerID: string, + ttl?: number, + ): Promise fetchChannel(slug: string): Promise renewChannel( slug: string, secret: string, ttl: number, - ): Promise> - destroyChannel(slug: string, secret: string): Promise - offer( - slug: string, - offer: RTCSessionDescriptionInit, - ttl: number, - ): Promise - answer( - slug: string, - offerID: string, - answer: RTCSessionDescriptionInit, - ttl: number, ): Promise - fetchAnswer(slug: string, offerID: string): Promise + destroyChannel(slug: string, secret: string): Promise } export class RedisChannelRepo implements ChannelRepo { @@ -47,7 +40,10 @@ export class RedisChannelRepo implements ChannelRepo { this.client = new Redis(redisURL) } - async createChannel(ttl: number = config.channel.ttl): Promise { + async createChannel( + uploaderPeerID: string, + ttl: number = config.channel.ttl, + ): Promise { const shortSlug = await this.generateShortSlug() const longSlug = await this.generateLongSlug() @@ -55,6 +51,7 @@ export class RedisChannelRepo implements ChannelRepo { secret: crypto.randomUUID(), longSlug, shortSlug, + uploaderPeerID, } const channelStr = this.serializeChannel(channel) @@ -82,20 +79,16 @@ export class RedisChannelRepo implements ChannelRepo { slug: string, secret: string, ttl: number = config.channel.ttl, - ): Promise> { + ): Promise { const channel = await this.fetchChannel(slug) if (!channel || channel.secret !== secret) { - return {} + return false } await this.client.expire(this.getLongSlugKey(channel.longSlug), ttl) await this.client.expire(this.getShortSlugKey(channel.shortSlug), ttl) - const offerKey = this.getOfferKey(channel.shortSlug) - const offers = await this.client.hgetall(offerKey) - return Object.fromEntries( - Object.entries(offers).map(([offerID, offer]) => [offerID, JSON.parse(offer)]), - ) as Record + return true } async destroyChannel(slug: string, secret: string): Promise { @@ -108,54 +101,6 @@ export class RedisChannelRepo implements ChannelRepo { await this.client.del(this.getShortSlugKey(channel.shortSlug)) } - async offer( - slug: string, - offer: RTCSessionDescriptionInit, - ttl: number = config.channel.ttl, - ): Promise { - const channel = await this.fetchChannel(slug) - if (!channel) { - return '' - } - - const offerID = crypto.randomUUID() - const offerKey = this.getOfferKey(channel.shortSlug) - await this.client.hset(offerKey, offerID, JSON.stringify(offer)) - await this.client.expire(offerKey, ttl) - - return offerID - } - - async answer( - slug: string, - offerID: string, - answer: RTCSessionDescriptionInit, - ttl: number = config.channel.ttl, - ): Promise { - const channel = await this.fetchChannel(slug) - if (!channel) { - return false - } - - const answerKey = this.getAnswerKey(channel.shortSlug, offerID) - await this.client.setex(answerKey, ttl, JSON.stringify(answer)) - - const offerKey = this.getOfferKey(channel.shortSlug) - await this.client.hdel(offerKey, offerID) - - return true - } - - async fetchAnswer(slug: string, offerID: string): Promise { - const answerKey = this.getAnswerKey(slug, offerID) - const answer = await this.client.get(answerKey) - if (answer) { - return JSON.parse(answer) as RTCSessionDescriptionInit - } - - return null - } - private async generateShortSlug(): Promise { for (let i = 0; i < config.shortSlug.maxAttempts; i++) { const slug = generateShortSlug() @@ -188,14 +133,6 @@ export class RedisChannelRepo implements ChannelRepo { return `long:${longSlug}` } - private getOfferKey(shortSlug: string): string { - return `offers:${shortSlug}` - } - - private getAnswerKey(shortSlug: string, offerID: string): string { - return `answers:${shortSlug}:${offerID}` - } - private serializeChannel(channel: Channel): string { return JSON.stringify(channel) } diff --git a/src/components/Downloader.tsx b/src/components/Downloader.tsx index 9a5ab98..b51b1f7 100644 --- a/src/components/Downloader.tsx +++ b/src/components/Downloader.tsx @@ -137,7 +137,11 @@ export function PasswordEntry({ ) } -export default function Downloader({ slug }: { slug: string }): JSX.Element { +export default function Downloader({ + uploaderPeerID, +}: { + uploaderPeerID: string +}): JSX.Element { const { filesInfo, isConnected, @@ -150,7 +154,7 @@ export default function Downloader({ slug }: { slug: string }): JSX.Element { stopDownload, totalSize, bytesDownloaded, - } = useDownloader(slug) + } = useDownloader(uploaderPeerID) if (isDone && filesInfo) { return ( diff --git a/src/components/Uploader.tsx b/src/components/Uploader.tsx index 6060ca8..4902843 100644 --- a/src/components/Uploader.tsx +++ b/src/components/Uploader.tsx @@ -24,9 +24,8 @@ export default function Uploader({ onStop: () => void }): JSX.Element { const peer = useWebRTC() - const uploadID = useRef(crypto.randomUUID()) const { isLoading, error, longSlug, shortSlug, longURL, shortURL } = - useUploaderChannel(uploadID.current) + useUploaderChannel(peer.id) const connections = useUploaderConnections(peer, files, password) if (isLoading || !longSlug || !shortSlug) { diff --git a/src/components/WebRTCProvider.tsx b/src/components/WebRTCProvider.tsx index d33e1c0..33c838d 100644 --- a/src/components/WebRTCProvider.tsx +++ b/src/components/WebRTCProvider.tsx @@ -1,26 +1,15 @@ 'use client' -import React, { useState, useEffect, useContext } from 'react' +import React, { useState, useEffect, useContext, useRef } from 'react' import Loading from './Loading' - -export type WebRTCValue = { - createOffer: () => Promise - createAnswer: ( - offer: RTCSessionDescriptionInit, - ) => Promise - setRemoteDescription: ( - description: RTCSessionDescriptionInit, - ) => Promise - addIceCandidate: (candidate: RTCIceCandidateInit) => Promise - onIceCandidate: (handler: (candidate: RTCIceCandidate | null) => void) => void - onDataChannel: (handler: (channel: RTCDataChannel) => void) => void - createDataChannel: (label: string) => RTCDataChannel -} +import Peer from 'peerjs' const ICE_SERVERS: RTCConfiguration = { - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + iceServers: [{ urls: process.env.NEXT_PUBLIC_STUN_SERVER ?? 'stun:stun.l.google.com:19302' }], } +export type WebRTCValue = Peer + const WebRTCContext = React.createContext(null) export const useWebRTC = (): WebRTCValue => { @@ -31,57 +20,53 @@ export const useWebRTC = (): WebRTCValue => { return value } +let globalPeer: Peer | null = null + +async function getPeer(): Promise { + if (!globalPeer) { + globalPeer = new Peer({ + config: ICE_SERVERS, + }) + } + + if (globalPeer.id) { + return globalPeer + } + + await new Promise((resolve) => { + globalPeer?.on('open', (id) => { + console.log('[WebRTCProvider] Peer ID:', id) + resolve() + }) + }) + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return globalPeer! +} + export function WebRTCProvider({ - servers = ICE_SERVERS, children, }: { - servers?: RTCConfiguration children?: React.ReactNode }): JSX.Element { - const [peerConnection, setPeerConnection] = - useState(null) - const [loaded, setLoaded] = useState(false) + const [peerConnection, setPeerConnection] = useState(globalPeer) useEffect(() => { - const pc = new RTCPeerConnection(servers) - setPeerConnection(pc) - setLoaded(true) + getPeer().then((pc) => { + setPeerConnection(pc) + }) return () => { - pc.close() + setPeerConnection(null) } - }, [servers]) + }, []) - if (!loaded || !peerConnection) { + if (!peerConnection) { return } - const webRTCValue: WebRTCValue = { - createOffer: async () => { - const offer = await peerConnection.createOffer() - await peerConnection.setLocalDescription(offer) - return offer - }, - createAnswer: async (offer) => { - await peerConnection.setRemoteDescription(offer) - const answer = await peerConnection.createAnswer() - await peerConnection.setLocalDescription(answer) - return answer - }, - setRemoteDescription: (description) => - peerConnection.setRemoteDescription(description), - addIceCandidate: (candidate) => peerConnection.addIceCandidate(candidate), - onIceCandidate: (handler) => { - peerConnection.onicecandidate = (event) => handler(event.candidate) - }, - onDataChannel: (handler) => { - peerConnection.ondatachannel = (event) => handler(event.channel) - }, - createDataChannel: (label) => peerConnection.createDataChannel(label), - } - return ( - + {children} ) diff --git a/src/hooks/useDownloader.ts b/src/hooks/useDownloader.ts index 7bab3f8..100b616 100644 --- a/src/hooks/useDownloader.ts +++ b/src/hooks/useDownloader.ts @@ -15,7 +15,6 @@ import { mobileVendor, mobileModel, } from 'react-device-detect' -import { useQuery, useMutation } from '@tanstack/react-query' const cleanErrorMessage = (errorMessage: string): string => errorMessage.startsWith('Could not connect to peer') ? 'Could not connect to the uploader. Did they close their browser?' @@ -23,7 +22,7 @@ const cleanErrorMessage = (errorMessage: string): string => const getZipFilename = (): string => `filepizza-download-${Date.now()}.zip` -export function useDownloader(slug: string): { +export function useDownloader(uploaderPeerID: string): { filesInfo: Array<{ fileName: string; size: number; type: string }> | null isConnected: boolean isPasswordRequired: boolean @@ -55,98 +54,10 @@ export function useDownloader(slug: string): { const [bytesDownloaded, setBytesDownloaded] = useState(0) const [errorMessage, setErrorMessage] = useState(null) - const { - isLoading: offerLoading, - error: offerError, - data: offerData, - } = useQuery({ - queryKey: ['offer', slug], - queryFn: async () => { - const offer = await peer.createOffer() - const response = await fetch('/api/offer', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slug, offer }), - }) - if (!response.ok) { - throw new Error('Could not offer connection to uploader') - } - const data = await response.json() - return { offerID: data.offerID, offer } - }, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - staleTime: Infinity, - }) - - const answerCheckMutation = useMutation({ - mutationFn: async (body: { slug: string, offerID: string }) => { - const response = await fetch(`/api/answer?slug=${body.slug}&offerID=${body.offerID}`, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - if (!response.ok) { - throw new Error('Network response was not ok') - } - return response.json() - } - }) - - useEffect(() => { - if (!offerData || isConnected) return - - let timeout: NodeJS.Timeout | null = null - - const run = (): void => { - timeout = setTimeout(() => { - console.log('Checking for answer', offerData) - answerCheckMutation.mutate( - { slug, offerID: offerData?.offerID }, - { - onSuccess: (data) => { - if (data.answer) { - console.log('Answer check success', data) - setIsConnected(true) - if (timeout) clearTimeout(timeout) - - peer.setRemoteDescription(data.answer).then(() => { - peer.addIceCandidate() - const conn = peer.createDataChannel('download') - conn.onopen = () => { - console.log('Connection opened') - } - conn.onerror = (e) => { - console.error('Error setting remote description', e) - } - conn.onclose = () => { - console.log('Connection closed') - } - }).catch((e) => { - console.error('Error setting remote description', e) - }) - } - }, - onError: (e) => { - console.error('Error checking for answer', e) - }, - }, - ) - run() - }, 1000) - } - - run() - - return () => { - if (timeout) clearTimeout(timeout) - } - }, [offerData, isConnected]) - useEffect(() => { - return - // const conn = peer.connect(slug, { reliable: true }) - // setDataConnection(conn) + if (!peer) return + const conn = peer.connect(uploaderPeerID, { reliable: true }) + setDataConnection(conn) const handleOpen = () => { setIsConnected(true) diff --git a/src/hooks/useUploaderChannel.ts b/src/hooks/useUploaderChannel.ts index 8d78c2f..d922a6c 100644 --- a/src/hooks/useUploaderChannel.ts +++ b/src/hooks/useUploaderChannel.ts @@ -1,6 +1,5 @@ import { useQuery, useMutation } from '@tanstack/react-query' import { useEffect } from 'react' -import { useWebRTC } from '../components/WebRTCProvider' function generateURL(slug: string): string { const hostPrefix = @@ -14,7 +13,7 @@ function generateURL(slug: string): string { } export function useUploaderChannel( - uploadID: string, + uploaderPeerID: string, renewInterval = 5000, ): { isLoading: boolean @@ -24,13 +23,13 @@ export function useUploaderChannel( longURL: string | undefined shortURL: string | undefined } { - const peer = useWebRTC() const { isLoading, error, data } = useQuery({ - queryKey: ['uploaderChannel', uploadID], + queryKey: ['uploaderChannel', uploaderPeerID], queryFn: async () => { const response = await fetch('/api/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uploaderPeerID }), }) if (!response.ok) { throw new Error('Network response was not ok') @@ -86,22 +85,6 @@ export function useUploaderChannel( timeout = setTimeout(() => { renewMutation.mutate( { secret }, - { - onSuccess: (d) => { - Object.entries(d.offers).forEach(async ([offerID, offer]) => { - try { - const answer = await peer.createAnswer(offer as RTCSessionDescriptionInit) - peer.onDataChannel((channel) => { - console.log('Data channel opened', channel) - }) - console.log('Created answer:', answer) - answerMutation.mutate({ offerID, answer }) - } catch (e) { - console.error('Error creating answer:', e) - } - }) - }, - }, ) run() }, renewInterval) diff --git a/src/hooks/useUploaderConnections.ts b/src/hooks/useUploaderConnections.ts index 0b28302..58fdeca 100644 --- a/src/hooks/useUploaderConnections.ts +++ b/src/hooks/useUploaderConnections.ts @@ -286,10 +286,10 @@ export function useUploaderConnections( }) } - // peer.on('connection', listener) + peer.on('connection', listener) return () => { - // peer.off('connection') + peer.off('connection', listener) } }, [peer, files, password]) diff --git a/src/messages.ts b/src/messages.ts index b7b3033..6ed6bd6 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -5,6 +5,7 @@ export enum MessageType { Info = 'Info', Start = 'Start', Chunk = 'Chunk', + Pause = 'Pause', Done = 'Done', Error = 'Error', PasswordRequired = 'PasswordRequired',