Skip to content

Commit

Permalink
Stream token as query string / HLS config for less buffering (#4)
Browse files Browse the repository at this point in the history
* Set more secuirty headers

* Modify HLS.js options for less buffering

* Pass stream token as query param
  • Loading branch information
nicholasodonnell authored Dec 17, 2023
1 parent abc6352 commit 3986c5a
Show file tree
Hide file tree
Showing 8 changed files with 85 additions and 20 deletions.
16 changes: 16 additions & 0 deletions src/actions/getStreamToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ResponseCookies } from 'next/dist/compiled/@edge-runtime/cookies'
import { cookies, headers } from 'next/headers'

import { STREAM_TOKEN_COOKIE_NAME } from '../constants'

export function getStreamToken(): string {
const headersStore = headers()
const cookiesStore = cookies()
const responseCookies = new ResponseCookies(headersStore as Headers)

const streamTokenCookie =
cookiesStore.get(STREAM_TOKEN_COOKIE_NAME) ??
responseCookies.get(STREAM_TOKEN_COOKIE_NAME)

return streamTokenCookie?.value as string
}
19 changes: 19 additions & 0 deletions src/actions/setGlobalHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,23 @@ export function setGlobalHeaders(
// Cache
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
response.headers.set('Pragma', 'no-cache')

// Content Security Policy
response.headers.set('Content-Security-Policy', 'self')

// X-Content-Type-Options
response.headers.set('X-Content-Type-Options', 'nosniff')

// Referrer Policy
response.headers.set('Referrer-Policy', 'no-referrer')

// Strict-Transport-Security
response.headers.set('Strict-Transport-Security', 'max-age=63072000; preload')

// X-Frame-Options
response.headers.set('X-Frame-Options', 'DENY')

// Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy
response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp')
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin')
}
23 changes: 18 additions & 5 deletions src/actions/setStreamToken.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { v4 as uuid } from 'uuid'

import { STREAM_TOKEN_COOKIE_NAME } from '../constants'
import { sign } from '../lib/jwt'

export async function setStreamToken(response: NextResponse): Promise<void> {
const token = await sign({
viewerId: uuid(),
})
const ONE_DAY_MS = 1000 * 60 * 60 * 24 * 30

export async function setStreamToken(
request: NextRequest,
response: NextResponse,
): Promise<void> {
if (request.cookies.get(STREAM_TOKEN_COOKIE_NAME)) {
return
}

const token = await sign(
{
viewerId: uuid(),
},
{ exp: '1d' },
)

response.cookies.set({
expires: new Date(Date.now() + ONE_DAY_MS),
httpOnly: true,
name: STREAM_TOKEN_COOKIE_NAME,
path: '/',
Expand Down
12 changes: 8 additions & 4 deletions src/app/api/live/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import HttpError from 'http-errors'
import { NextRequest, NextResponse } from 'next/server'

import { RS_URL, STREAM_TOKEN_COOKIE_NAME } from '../../../constants'
import { RS_URL } from '../../../constants'
import { verify } from '../../../lib/jwt'
import * as logger from '../../../lib/logger'

Expand All @@ -10,16 +10,20 @@ export const fetchCache = 'force-no-store'
export const revalidate = 0

export async function GET(request: NextRequest): Promise<Response> {
const url: URL = new URL(request.nextUrl)
const ip =
request.headers.get('x-real-ip') ||
request.headers.get('x-forwarded-for') ||
request.ip
const token = request.cookies.get(STREAM_TOKEN_COOKIE_NAME)
const claims = token ? await verify(token.value) : undefined
const userAgent = request.headers.get('user-agent')

const token: string | null = url.searchParams.get('token')
const claims = token ? await verify(token) : undefined

const meta = {
geo: request.geo?.city as string,
ip: ip as string,
userAgent: userAgent as string,
viewerId: claims?.viewerId as string,
}

Expand All @@ -40,7 +44,7 @@ export async function GET(request: NextRequest): Promise<Response> {
},
})
} catch (e: any) {
const error = new Error(`Failed to fetch playlist: ${e.message}`, {
const error = new Error(`Failed to fetch stream: ${e.message}`, {
cause: e,
})

Expand Down
7 changes: 6 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getStreamOnline } from '../actions/getStreamOnline'
import { getStreamToken } from '../actions/getStreamToken'
import { Header } from '../components/header'
import { Logo } from '../components/logo'
import { Main } from '../components/main'
Expand All @@ -11,6 +12,7 @@ export const fetchCache = 'force-no-store'

export default async function Page() {
const online: boolean = await getStreamOnline()
const token: string = getStreamToken()

return (
<>
Expand All @@ -19,7 +21,10 @@ export default async function Page() {
</Header>
<Main className="relative items-center justify-center overflow-hidden">
{online ? (
<Player className="absolute h-full" src="/live.m3u8" />
<Player
className="absolute h-full"
src={`/live.m3u8?token=${token}`}
/>
) : (
<Offline className="flex flex-1 flex-col items-center justify-center" />
)}
Expand Down
9 changes: 5 additions & 4 deletions src/components/player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ const hlsConfig = {
enableWorker: true, // use workers
initialLiveManifestSize: 2, // preload 2 chunks before autostart
liveDurationInfinity: true, // instructs browser that video is live
liveMaxLatencyDuration: 10, // if higher than this, adujst to liveSyncDuration
liveSyncDuration: 5, // how close to live to target? shorter than 3sec causes frequent buffering issues
liveMaxLatencyDuration: 40, // maximum latency allowed before HLS.js seeks forward to reduce the latency (A lower value here helps in keeping the stream closer to real-time)
liveSyncDuration: 30, // how close to live to target? shorter than 3sec causes frequent buffering issues
lowLatencyMode: true, // enable low latency mode
maxBufferLength: 10, // limit forward buffer
maxLiveSyncPlaybackRate: 2, // if running behind, speed up video
maxBufferLength: 30, // maximum length, in seconds, of the buffer
maxBufferSize: 60 * 1000 * 1000, // 60 MB maximum buffer size in bytes
maxMaxBufferLength: 60, // safeguard to ensure buffering doesn't exceed this value
nudgeMaxRetry: 5, // increase retries before buffer stalled
progressive: true, // use fetch instead of xhr
testBandwidth: false, // disable auto bandwidth estimation
Expand Down
12 changes: 10 additions & 2 deletions src/lib/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ import type { JWTPayload } from 'jose'

import { SIGNING_SECRET } from '../constants'

export type SignOptions = {
alg?: string
exp?: string
}

const secret = new TextEncoder().encode(SIGNING_SECRET)
const alg = 'HS256'

export async function sign(claims: JWTPayload): Promise<string> {
export async function sign(
claims: JWTPayload,
{ alg = 'HS256', exp = '1d' }: SignOptions = {},
): Promise<string> {
return await new jose.SignJWT(claims)
.setProtectedHeader({ alg })
.setIssuedAt()
.setExpirationTime('1d')
.setExpirationTime(exp)
.sign(secret)
}

Expand Down
7 changes: 3 additions & 4 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ function isHomepage(pathname: string): boolean {
}

export async function middleware(request: NextRequest) {
const url = new URL(request.url)
const pathname = request.nextUrl.pathname
const url: URL = new URL(request.nextUrl)
const response: NextResponse = NextResponse.next()

setGlobalHeaders(response, { origin: url.origin })

if (isHomepage(pathname)) {
await setStreamToken(response)
if (isHomepage(url.pathname)) {
await setStreamToken(request, response)
}

return response
Expand Down

0 comments on commit 3986c5a

Please sign in to comment.