revert back to peerjs

pull/134/head
Alex Kern 12 months ago
parent ba9d0ec739
commit 2dbe638387
No known key found for this signature in database
GPG Key ID: EF051FACCACBEE25

@ -1,38 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { channelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> {
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<NextResponse> {
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 })
}

@ -1,7 +1,13 @@
import { NextResponse } from 'next/server'
import { Channel, channelRepo } from '../../../channel'
export async function POST(): Promise<NextResponse> {
const channel: Channel = await channelRepo.createChannel()
export async function POST(request: Request): Promise<NextResponse> {
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)
}

@ -1,17 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { channelRepo } from '../../../channel'
export async function POST(request: NextRequest): Promise<NextResponse> {
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 })
}

@ -12,6 +12,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
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 })
}

@ -30,7 +30,7 @@ export default async function DownloadPage({
<Spinner direction="down" />
<Wordmark />
<WebRTCProvider>
<Downloader slug={slug} />
<Downloader uploaderPeerID={channel.uploaderPeerID} />
</WebRTCProvider>
</div>
)

@ -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<Channel>
createChannel(
uploaderPeerID: string,
ttl?: number,
): Promise<Channel>
fetchChannel(slug: string): Promise<Channel | null>
renewChannel(
slug: string,
secret: string,
ttl: number,
): Promise<Record<string, RTCSessionDescriptionInit>>
destroyChannel(slug: string, secret: string): Promise<void>
offer(
slug: string,
offer: RTCSessionDescriptionInit,
ttl: number,
): Promise<string>
answer(
slug: string,
offerID: string,
answer: RTCSessionDescriptionInit,
ttl: number,
): Promise<boolean>
fetchAnswer(slug: string, offerID: string): Promise<RTCSessionDescriptionInit | null>
destroyChannel(slug: string, secret: string): Promise<void>
}
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<Channel> {
async createChannel(
uploaderPeerID: string,
ttl: number = config.channel.ttl,
): Promise<Channel> {
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<Record<string, RTCSessionDescriptionInit>> {
): Promise<boolean> {
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<string, RTCSessionDescriptionInit>
return true
}
async destroyChannel(slug: string, secret: string): Promise<void> {
@ -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<string> {
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<boolean> {
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<RTCSessionDescriptionInit | null> {
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<string> {
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)
}

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

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

@ -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<RTCSessionDescriptionInit>
createAnswer: (
offer: RTCSessionDescriptionInit,
) => Promise<RTCSessionDescriptionInit>
setRemoteDescription: (
description: RTCSessionDescriptionInit,
) => Promise<void>
addIceCandidate: (candidate: RTCIceCandidateInit) => Promise<void>
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<WebRTCValue | null>(null)
export const useWebRTC = (): WebRTCValue => {
@ -31,57 +20,53 @@ export const useWebRTC = (): WebRTCValue => {
return value
}
let globalPeer: Peer | null = null
async function getPeer(): Promise<Peer> {
if (!globalPeer) {
globalPeer = new Peer({
config: ICE_SERVERS,
})
}
if (globalPeer.id) {
return globalPeer
}
await new Promise<void>((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<RTCPeerConnection | null>(null)
const [loaded, setLoaded] = useState(false)
const [peerConnection, setPeerConnection] = useState<Peer | null>(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 <Loading text="Initializing WebRTC connection..." />
}
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 (
<WebRTCContext.Provider value={webRTCValue}>
<WebRTCContext.Provider value={peerConnection}>
{children}
</WebRTCContext.Provider>
)

@ -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<string | null>(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)

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

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

@ -5,6 +5,7 @@ export enum MessageType {
Info = 'Info',
Start = 'Start',
Chunk = 'Chunk',
Pause = 'Pause',
Done = 'Done',
Error = 'Error',
PasswordRequired = 'PasswordRequired',

Loading…
Cancel
Save