Pular para o conteúdo principal

Assistir ao vivo

A visualização ao vivo é o que a plateia de fato vê, e é onde entregar a credencial errada ao browser é mais fácil.

1. O browser recebe um token de viewer, não a chave de API

app/api/battles/[id]/viewer-token/route.ts
export async function POST(_request: Request, context: Context): Promise<Response> {
const { id } = await context.params
const token = await mintViewerToken(id)
return Response.json(token)
}

Essa rota é o que torna a visualização ao vivo segura. O browser precisa de uma credencial para abrir o socket, e essa entrega uma com escopo de uma batalha e válida por 15 minutos, em vez de uma com escopo do casino inteiro que nunca expira.

Decodificado, de uma instância real:

{ "alg": "EdDSA", "typ": "JWT", "kid": "k1" }
{
"iss": "slotbattle",
"aud": "slotbattle-ws",
"tenant_id": "acme",
"battle_id": "btl_ClzIJ4ONnmwci6D87J9O",
"role": "viewer",
"exp": 1785731186
}

battle_id está embutido, então um token de uma batalha é inútil em outra.

Traduzindo o erro de chave de assinatura ausente

if (error.status === 500) {
return Response.json({
code: 'no_signing_key',
message:
'This SlotBattle instance has no viewer signing key, so live viewing is unavailable. ' +
'Ask the operator to configure or rotate one.',
}, { status: 503 })
}

Um 500 failed to mint viewer token não é bug na sua chamada: a instância não tem chave de assinatura de viewer. Batalhas abrem e preenchem normalmente, só a visualização ao vivo fica indisponível, e o ws_token some das respostas de criação e de assento porque o campo é opcional.

2. O handshake

app/battles/[id]/_components/live-battle.tsx
const socket = new WebSocket(ticket.ws_url, ['slotbattle.jwt', ticket.token])

Duas entradas: o nome fixo do subprotocolo, depois o token cru.

Browsers não conseguem definir headers arbitrários num handshake de WebSocket, e um token em query string cai em log de acesso, log de proxy e histórico do browser. A lista de subprotocolos é o único lugar alcançável pelo browser para colocar uma credencial, e é por isso que a API retorna o token como campo próprio e nunca dentro do ws_url.

Verificado contra uma instância em execução:

HandshakeResposta
Token válido101 Switching Protocols
Sem token401
Token inválido ou expirado401
O feed responde texto puro, não o envelope JSON de erro

É outro servidor, em outra porta. Não escreva um cliente que tente fazer parse de um upgrade falho como JSON.

3. Emita um token novo, sempre

const connect = useCallback(async () => {
// Always mint a FRESH token rather than reusing the one from the create
// response: by reconnect time it may be past its 15 minutes, and an
// expired token is a 401 on the handshake that looks like a revoked one.
const response = await fetch(`/api/battles/${battle.id}/viewer-token`, { method: 'POST' })
// …
})

Tokens são baratos e lobbies podem ficar abertos mais de 15 minutos. Reusar um vencido produz um 401 que parece problema de permissão.

4. Renderize a partir de um snapshot, depois aplique eventos

app/battles/[id]/page.tsx
const battle = await getBattle(id)
return <LiveBattle initialBattle={battle} />

Não há replay no socket. Um espectador que conecta no meio da batalha vê o que acontece dali em diante; nada é bufferizado e nada é reentregue. Uma UI que assume ter visto a sequência desde o começo renderiza um lobby quebrado para todo mundo que chegou tarde ou reconectou.

Busque o snapshot no servidor, entregue ao cliente, e deixe os eventos aplicarem por cima.

socket.onmessage = (message) => {
const event = JSON.parse(message.data as string) as PublicEvent

if (isLobbyLifecycleEvent(event.type)) {
// Seat shape changed or the battle ended — re-read rather than trying
// to patch local state from the event payload.
void refreshSnapshot()
} else {
// spin.started / segment.new / playlist.update: per-seat playback.
setSeatsLive((current) => applyWorkerEvent(current, event))
}
}

Dois ramos, e a divisão importa. Eventos de escopo de batalha mudam o formato da batalha, então disparam releitura do snapshot. Eventos de escopo de usuário carregam estado de reprodução por assento e são aplicados localmente: reler o snapshot a cada playlist.update seria uma tempestade de requisições por dados que não mudaram, já que esses chegam a cada poucos segundos, por assento.

Reler na mudança estrutural é o que mantém correto um cliente que perdeu eventos. Remendar estado local a partir de payloads de evento faz de todo frame perdido uma divergência permanente.

Só oito tipos de evento chegam a um browser. Todo o resto do vocabulário, incluindo browser.ready, capture.*, click.* e user.*, é interno e nunca vai chegar.

5. Reconecte direito

socket.onclose = () => {
if (stoppedRef.current) { setStatus('closed'); return }

const attempt = (attemptRef.current += 1)
const delay = Math.min(1000 * 2 ** attempt, 8000) + Math.random() * 400
setStatus('connecting')
window.setTimeout(() => {
void refreshSnapshot().then(connect)
}, delay)
}

Backoff, jitter, teto. Busque o snapshot de novo na volta, porque os eventos do intervalo se perderam de vez.

Pare de reconectar quando a batalha for terminal. Não há mais nada para receber, e um loop de reconexão contra batalha encerrada é ruído.

6. Vídeo

O socket não carrega segmentos de vídeo, mas carrega a playlist, inline, a cada playlist.update:

{
"type": "playlist.update",
"scope": "user",
"user_id": "usr_s0",
"data": {
"m3u8": "#EXTM3U\n#EXT-X-VERSION:7\n…\nseg_0000.m4s\n",
"url": "https://cdn.example/acme/battles/btl_x/usr_s0/index.m3u8",
"ended": false
}
}

A implementação óbvia aponta o hls.js para o hls_url do assento na CDN e deixa ele fazer polling:

// Looks right. Does not work for a LIVE battle.
const hls = new Hls()
hls.loadSource(seat.hls_url)
hls.attachMedia(video)

O gravador publica a primeira playlist alguns segundos depois de a batalha começar. Até lá aquela URL é 404, o hls.js trata 404 de manifesto como erro fatal de rede, e o tile fica preto pelo resto da execução. Não há exceção e não há retentativa que funcione.

Alimente-o com a playlist que chegou pelo socket, por um playlist loader customizado, para o player só ver uma playlist que já existe:

app/battles/[id]/_components/ws-hls-player.tsx
const hls = new Hls({ ...WS_HLS_CONFIG, pLoader: makePlaylistLoader() })
hls.loadSource('wsplaylist://seat/index.m3u8') // never fetched — served from memory
hls.attachMedia(video)

A CDN continua servindo os segmentos; só a playlist vai pelo socket. Duas coisas precisam estar certas para os segmentos carregarem:

Resolva as URIs. O gravador escreve nomes de segmento relativos (seg_0000.m4s), corretos para uma playlist servida do próprio diretório. Uma vez que essa playlist viaja por um WebSocket ela não tem localização, então o player resolve esses nomes contra a URL da página e dá 404 em todos. Reescreva-os contra data.url, incluindo os atributos URI="…", que é onde o EXT-X-MAP esconde o segmento de init. Sem esse segmento de init, o fMP4 nem decodifica.

Configure CORS na CDN. O hls.js busca segmentos com XMLHttpRequest, então um bucket ou distribuição que não envia Access-Control-Allow-Origin bloqueia todo segmento no browser enquanto o curl baixa normalmente. O browser reporta como falha de CORS; o player não reporta nada.

Depois que a batalha acaba não há socket nem corrida, então a gravação pronta toca direto do hls_url. A demo mantém os dois caminhos e alterna pelo status da batalha.

Os segmentos são enviados conforme são produzidos, o que é o que permite à plateia assistir a um assento enquanto ele ainda está girando. O gravador envia um segmento antes de publicar a playlist que o lista, então a playlist vinda do socket não precisa de corte na borda ao vivo.

Essa ordem só vale para segmentos que de fato sobem. Quando o object storage começa a recusar escritas, o gravador loga segment upload failed after retries e segue: a playlist continua anunciando aquele segmento, e o browser recebe 404 de um arquivo que nunca vai existir.

Reconheça isso pelo formato. Uma tempestade de 404 de segmento em todos os assentos ao mesmo tempo significa que o caminho de escrita está falhando, não o de leitura. Escala com o número de assentos: uma mesa de oito assentos já foi observada produzindo connection reset by peer e retry quota exceeded em PutObject para todos os assentos, enquanto uma mesa de dois assentos na mesma instância não produziu erro nenhum.

Manter o hls.js alguns segmentos atrás da borda ao vivo (liveSyncDurationCount: 3) com retentativas limitadas em erros fatais de rede deixa a reprodução atravessar o caso transitório. Não recupera segmentos que nunca foram armazenados.

Webhooks