more progress

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

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

@ -2,6 +2,6 @@ import { NextResponse } from 'next/server'
import { Channel, channelRepo } from '../../../channel' import { Channel, channelRepo } from '../../../channel'
export async function POST(): Promise<NextResponse> { export async function POST(): Promise<NextResponse> {
const channel: Channel = await channelRepo.create() const channel: Channel = await channelRepo.createChannel()
return NextResponse.json(channel) return NextResponse.json(channel)
} }

@ -13,7 +13,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
} }
try { try {
await channelRepo.destroy(slug, secret) await channelRepo.destroyChannel(slug, secret)
return NextResponse.json({ success: true }, { status: 200 }) return NextResponse.json({ success: true }, { status: 200 })
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(

@ -12,6 +12,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ error: 'Offer is required' }, { status: 400 }) return NextResponse.json({ error: 'Offer is required' }, { status: 400 })
} }
await channelRepo.offer(slug, offer) const offerID = await channelRepo.offer(slug, offer)
return NextResponse.json({ success: true }) 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 }) return NextResponse.json({ error: 'Secret is required' }, { status: 400 })
} }
const offers = await channelRepo.renew(slug, secret) const offers = await channelRepo.renewChannel(slug, secret)
return NextResponse.json({ success: true, offers }) return NextResponse.json({ success: true, offers })
} }

@ -19,7 +19,7 @@ export default async function DownloadPage({
params: { slug: string[] } params: { slug: string[] }
}): Promise<JSX.Element> { }): Promise<JSX.Element> {
const slug = normalizeSlug(params.slug) const slug = normalizeSlug(params.slug)
const channel = await channelRepo.fetch(slug) const channel = await channelRepo.fetchChannel(slug)
if (!channel) { if (!channel) {
notFound() notFound()

@ -18,14 +18,26 @@ const ChannelSchema = z.object({
}) })
export interface ChannelRepo { export interface ChannelRepo {
create(ttl?: number): Promise<Channel> createChannel(ttl?: number): Promise<Channel>
fetch(slug: string): Promise<Channel | null> fetchChannel(slug: string): Promise<Channel | null>
renew( renewChannel(
slug: string, slug: string,
secret: string, secret: string,
ttl: number, ttl: number,
): Promise<RTCSessionDescriptionInit[]> ): Promise<Record<string, RTCSessionDescriptionInit>>
destroy(slug: string, secret: string): Promise<void> 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>
} }
export class RedisChannelRepo implements ChannelRepo { export class RedisChannelRepo implements ChannelRepo {
@ -35,7 +47,7 @@ export class RedisChannelRepo implements ChannelRepo {
this.client = new Redis(redisURL) this.client = new Redis(redisURL)
} }
async create(ttl: number = config.channel.ttl): Promise<Channel> { async createChannel(ttl: number = config.channel.ttl): Promise<Channel> {
const shortSlug = await this.generateShortSlug() const shortSlug = await this.generateShortSlug()
const longSlug = await this.generateLongSlug() const longSlug = await this.generateLongSlug()
@ -52,7 +64,7 @@ export class RedisChannelRepo implements ChannelRepo {
return channel return channel
} }
async fetch(slug: string, scrubSecret = false): Promise<Channel | null> { async fetchChannel(slug: string, scrubSecret = false): Promise<Channel | null> {
const shortChannelStr = await this.client.get(this.getShortSlugKey(slug)) const shortChannelStr = await this.client.get(this.getShortSlugKey(slug))
if (shortChannelStr) { if (shortChannelStr) {
return this.deserializeChannel(shortChannelStr, scrubSecret) return this.deserializeChannel(shortChannelStr, scrubSecret)
@ -66,32 +78,28 @@ export class RedisChannelRepo implements ChannelRepo {
return null return null
} }
async renew( async renewChannel(
slug: string, slug: string,
secret: string, secret: string,
ttl: number = config.channel.ttl, ttl: number = config.channel.ttl,
): Promise<RTCSessionDescriptionInit[]> { ): Promise<Record<string, RTCSessionDescriptionInit>> {
const channel = await this.fetch(slug) const channel = await this.fetchChannel(slug)
if (!channel || channel.secret !== secret) { if (!channel || channel.secret !== secret) {
return [] return {}
} }
await this.client.expire(this.getLongSlugKey(channel.longSlug), ttl) await this.client.expire(this.getLongSlugKey(channel.longSlug), ttl)
await this.client.expire(this.getShortSlugKey(channel.shortSlug), ttl) await this.client.expire(this.getShortSlugKey(channel.shortSlug), ttl)
const offerKey = this.getOfferKey(channel.shortSlug) const offerKey = this.getOfferKey(channel.shortSlug)
const offers = await this.client.lrange(offerKey, 0, -1) const offers = await this.client.hgetall(offerKey)
if (offers.length > 0) { return Object.fromEntries(
return offers.map((offer) => Object.entries(offers).map(([offerID, offer]) => [offerID, JSON.parse(offer)]),
JSON.parse(offer), ) as Record<string, RTCSessionDescriptionInit>
) as RTCSessionDescriptionInit[]
}
return []
} }
async destroy(slug: string, secret: string): Promise<void> { async destroyChannel(slug: string, secret: string): Promise<void> {
const channel = await this.fetch(slug) const channel = await this.fetchChannel(slug)
if (!channel || channel.secret !== secret) { if (!channel || channel.secret !== secret) {
return return
} }
@ -104,18 +112,48 @@ export class RedisChannelRepo implements ChannelRepo {
slug: string, slug: string,
offer: RTCSessionDescriptionInit, offer: RTCSessionDescriptionInit,
ttl: number = config.channel.ttl, ttl: number = config.channel.ttl,
): Promise<void> { ): Promise<string> {
const channel = await this.fetch(slug) const channel = await this.fetchChannel(slug)
if (!channel) { if (!channel) {
return return ''
} }
const offerID = crypto.randomUUID()
const offerKey = this.getOfferKey(channel.shortSlug) const offerKey = this.getOfferKey(channel.shortSlug)
await this.client.rpush(offerKey, JSON.stringify(offer)) await this.client.hset(offerKey, offerID, JSON.stringify(offer))
await this.client.expire(offerKey, ttl) await this.client.expire(offerKey, ttl)
await this.client.expire(this.getLongSlugKey(channel.longSlug), ttl) return offerID
await this.client.expire(this.getShortSlugKey(channel.shortSlug), ttl) }
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> { private async generateShortSlug(): Promise<string> {
@ -154,6 +192,10 @@ export class RedisChannelRepo implements ChannelRepo {
return `offers:${shortSlug}` return `offers:${shortSlug}`
} }
private getAnswerKey(shortSlug: string, offerID: string): string {
return `answers:${shortSlug}:${offerID}`
}
private serializeChannel(channel: Channel): string { private serializeChannel(channel: Channel): string {
return JSON.stringify(channel) return JSON.stringify(channel)
} }

@ -15,7 +15,7 @@ import {
mobileVendor, mobileVendor,
mobileModel, mobileModel,
} from 'react-device-detect' } from 'react-device-detect'
import { useQuery } from '@tanstack/react-query' import { useQuery, useMutation } from '@tanstack/react-query'
const cleanErrorMessage = (errorMessage: string): string => const cleanErrorMessage = (errorMessage: string): string =>
errorMessage.startsWith('Could not connect to peer') errorMessage.startsWith('Could not connect to peer')
? 'Could not connect to the uploader. Did they close their browser?' ? 'Could not connect to the uploader. Did they close their browser?'
@ -72,7 +72,7 @@ export function useDownloader(slug: string): {
throw new Error('Could not offer connection to uploader') throw new Error('Could not offer connection to uploader')
} }
const data = await response.json() const data = await response.json()
return data.success return { offerID: data.offerID, offer }
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false, refetchOnMount: false,
@ -80,6 +80,69 @@ export function useDownloader(slug: string): {
staleTime: Infinity, 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(() => { useEffect(() => {
return return
// const conn = peer.connect(slug, { reliable: true }) // const conn = peer.connect(slug, { reliable: true })

@ -63,7 +63,19 @@ export function useUploaderChannel(
}, },
}) })
// TODO(@kern): add a way to post an answer back to the client const answerMutation = useMutation({
mutationFn: async ({ offerID, answer }: { offerID: string, answer: RTCSessionDescriptionInit }) => {
const response = await fetch('/api/answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: shortSlug, offerID, answer }),
})
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
},
})
useEffect(() => { useEffect(() => {
if (!secret || !shortSlug) return if (!secret || !shortSlug) return
@ -76,13 +88,16 @@ export function useUploaderChannel(
{ secret }, { secret },
{ {
onSuccess: (d) => { onSuccess: (d) => {
d.offers.forEach(async (offer) => { Object.entries(d.offers).forEach(async ([offerID, offer]) => {
try { try {
const answer = await peer.createAnswer(offer) const answer = await peer.createAnswer(offer as RTCSessionDescriptionInit)
peer.onDataChannel((channel) => {
console.log('Data channel opened', channel)
})
console.log('Created answer:', answer) console.log('Created answer:', answer)
// TODO: Send this answer back to the client answerMutation.mutate({ offerID, answer })
} catch (error) { } catch (e) {
console.error('Error creating answer:', error) console.error('Error creating answer:', e)
} }
}) })
}, },
@ -97,7 +112,7 @@ export function useUploaderChannel(
return () => { return () => {
if (timeout) clearTimeout(timeout) if (timeout) clearTimeout(timeout)
} }
}, [secret, shortSlug, renewMutation, renewInterval]) }, [secret, shortSlug, renewMutation, answerMutation, renewInterval])
return { return {
isLoading, isLoading,

Loading…
Cancel
Save