Processamento de arquivos em tempo real com Deno e WebSockets
Clique em um botão no navegador, deixe o Deno calcular o hash de um arquivo na sua máquina e receba
a contagem de bytes e o checksum SHA-256 dele via WebSocket. Este exemplo lê um único arquivo fixo,
sample.txt, e aceita até 1 MiB. O navegador envia um comando e recebe o resultado; ele não faz upload do
arquivo.
Este passo a passo usa Deno 2.9.6 e Chromium no Linux, com Bash para os comandos de terminal.
Consulte o guia de instalação do Deno se você precisar
do runtime e confira sua versão com deno --version. Não há pacotes para instalar.
Por que Deno?
O Deno fornece o servidor HTTP, o upgrade para WebSocket, as APIs de arquivos e a Web Crypto usados aqui. Suas flags de permissão nos permitem conceder à aplicação leitura de dois arquivos e acesso de rede a um único endereço e porta de loopback. O exemplo não precisa de acesso de escrita, a subprocessos nem ao ambiente.
Configurando um servidor WebSocket
A partir de um diretório de sua escolha, cole isto no Bash. Isso cria um novo projeto e um arquivo
de três bytes contendo abc, sem quebra de linha no final. Se deno-checksum já existir, a criação
falha sem sobrescrevê-lo. Os parênteses mantêm seu terminal no diretório pai.
(
mkdir deno-checksum &&
cd deno-checksum &&
printf 'abc' > sample.txt
)
Salve o seguinte como deno-checksum/server.ts. Ele serve a página do navegador em / e faz o upgrade
das requisições em /ws usando a API de WebSocket do Deno.
Somente o comando de texto exato hash inicia o trabalho. Nenhuma mensagem pode escolher um caminho
de arquivo.
const hostname = '127.0.0.1'
const port = 8000
const origin = `http://${hostname}:${port}`
const maxFileBytes = 1024 * 1024
const html = await Deno.readTextFile('./index.html')
let busy = false
class FileProblem extends Error {}
async function hashSample(): Promise<{ bytes: number; sha256: string }> {
using file = await Deno.open('./sample.txt', { read: true })
if (!(await file.stat()).isFile) {
throw new FileProblem('Use a regular file for sample.txt.')
}
// One extra byte distinguishes an exact-limit file from an oversized file.
const buffer = new Uint8Array(maxFileBytes + 1)
let bytes = 0
while (bytes < buffer.length) {
const count = await file.read(buffer.subarray(bytes))
if (count === null) break
bytes += count
}
if (bytes > maxFileBytes) {
throw new FileProblem('sample.txt exceeds 1 MiB.')
}
const digest = await crypto.subtle.digest('SHA-256', buffer.subarray(0, bytes))
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
return { bytes, sha256 }
}
Deno.serve({ hostname, port, onListen: () => console.log(`Open ${origin}/`) }, (req) => {
const url = new URL(req.url)
if (req.method !== 'GET' || url.origin !== origin) {
return new Response('Not found', { status: 404 })
}
if (url.pathname === '/') {
return new Response(html, {
headers: { 'content-type': 'text/html; charset=utf-8' },
})
}
if (url.pathname !== '/ws') {
return new Response('Not found', { status: 404 })
}
if (req.headers.get('origin') !== origin) {
return new Response('Forbidden', { status: 403 })
}
if (req.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
return new Response('WebSocket required', { status: 426 })
}
const { socket, response } = Deno.upgradeWebSocket(req)
function send(message: object): void {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message))
}
}
socket.addEventListener('message', async (event) => {
if (event.data !== 'hash') {
send({ type: 'error', message: 'Send the text command hash.' })
return
}
if (busy) {
send({ type: 'error', message: 'Server is busy. Try again.' })
return
}
busy = true
try {
send({ type: 'started' })
const result = await hashSample()
send({ type: 'result', ...result })
} catch (error) {
const message = error instanceof FileProblem
? error.message
: error instanceof Deno.errors.NotFound
? 'sample.txt was not found.'
: error instanceof Deno.errors.NotCapable || error instanceof Deno.errors.PermissionDenied
? 'Read permission for sample.txt was denied.'
: 'Could not hash sample.txt.'
send({ type: 'error', message })
} finally {
busy = false
}
})
return response
})
file.read() pode retornar menos bytes
do que o solicitado, então o loop continua até o EOF ou até o buffer ficar cheio. using fecha o
arquivo quando a função termina, inclusive em caso de falha. Mantenha sample.txt como um arquivo local
comum e não o altere durante uma requisição: isto não é um snapshot do sistema de arquivos.
O buffer de leitura comporta no máximo 1 MiB mais um byte, mesmo que o arquivo cresça. Esse limite
importa porque crypto.subtle.digest() recebe sua entrada em memória,
sem streaming. Isso não significa que o servidor inteiro use apenas 1 MiB de memória.
Implementação do cliente
Salve isto como deno-checksum/index.html. O botão fica desabilitado até o socket abrir e enquanto uma
requisição estiver pendente. Os resultados substituem a exibição anterior; nada é salvo em disco.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>Deno file checksum</title>
<style>
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
</style>
</head>
<body>
<h1>Hash sample.txt</h1>
<button type="button" disabled>Hash file</button>
<p role="status">Connecting…</p>
<pre aria-label="Checksum result"></pre>
<script type="module">
const button = document.querySelector('button')
const status = document.querySelector('[role="status"]')
const result = document.querySelector('pre')
if (!(button instanceof HTMLButtonElement) || !status || !result) {
throw new Error('Missing page controls')
}
const socket = new WebSocket(`ws://${location.host}/ws`)
let pending = false
socket.addEventListener('open', () => {
status.textContent = 'Ready.'
button.disabled = false
})
button.addEventListener('click', () => {
if (pending || socket.readyState !== WebSocket.OPEN) return
pending = true
button.disabled = true
result.textContent = ''
status.textContent = 'Waiting for the server…'
socket.send('hash')
})
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.type === 'started') {
status.textContent = 'Hashing sample.txt…'
return
}
if (message.type === 'result') {
result.textContent = JSON.stringify(message, null, 2)
status.textContent = 'Done.'
} else if (message.type === 'error') {
status.textContent = message.message
}
pending = false
button.disabled = false
})
socket.addEventListener('error', () => {
status.textContent = 'Connection error. Check the server.'
button.disabled = true
})
socket.addEventListener('close', () => {
status.textContent = 'Disconnected. Reload to reconnect.'
button.disabled = true
pending = false
})
</script>
</body>
</html>
Executando o servidor
No mesmo diretório pai, execute:
(
cd deno-checksum &&
deno run --no-config --no-prompt \
--allow-net=127.0.0.1:8000 \
--allow-read=./index.html,./sample.txt server.ts
)
--no-config evita herdar a configuração do Deno de um projeto que o contenha. --no-prompt faz com
que permissões ausentes falhem em vez de pedir acesso mais amplo. A permissão de leitura cobre a
página carregada na inicialização e o arquivo de exemplo aberto a cada requisição. O acesso de rede
é restrito a 127.0.0.1:8000; o servidor também se vincula explicitamente a esse endereço.
Abra http://127.0.0.1:8000/ e selecione Hash file. Use exatamente
esse endereço, em vez de abrir o arquivo HTML diretamente ou usar localhost: o handshake do WebSocket
verifica a origem da página. Quando o status mudar para Done.,
o resultado deve ser:
{
"type": "result",
"bytes": 3,
"sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
}
Em outro terminal, no diretório pai, verifique de forma independente os mesmos bytes com o
sha256sum do Linux:
sha256sum deno-checksum/sample.txt
O primeiro campo da saída deve corresponder a sha256. Um valor diferente pode significar que seu editor
adicionou uma quebra de linha. Altere o arquivo depois que a requisição terminar e selecione
Hash file novamente para calcular o hash do novo conteúdo.
Arquivos vazios e dados binários também funcionam; o nome .txt não causa decodificação de texto.
Pare o servidor com Ctrl+C ao terminar.
Se a inicialização informar um endereço ocupado, escolha uma porta livre e altere tanto const port
quanto a porta em --allow-net antes de tentar novamente. Se index.html estiver ausente, a inicialização é
interrompida. Já a ausência de sample.txt produz
sample.txt was not found. no navegador; restaure o arquivo e clique
novamente. Um arquivo maior que o limite produz
sample.txt exceeds 1 MiB. sem retornar um checksum.
Entendendo o ciclo de vida do WebSocket
O cliente só envia enquanto readyState for WebSocket.OPEN. O servidor primeiro envia started e
depois result ou error. Uma operação iniciada não é evidência de que o arquivo foi lido
com sucesso, e as mensagens de status não são atualizações de progresso em porcentagem.
Se o navegador se desconectar durante o cálculo do hash, a operação limitada é concluída e o
servidor descarta a resposta assim que o socket não estiver mais aberto. O bloco finally libera a
flag de ocupado para as requisições seguintes. Recarregue a página para reconectar e solicitar um
novo checksum. Não há nova tentativa automática, histórico de tarefas nem recuperação de um
resultado perdido.
Boas práticas de segurança
Mantenha esta demonstração local. Vincular o servidor ao loopback e verificar o host HTTP e a origem do WebSocket restringem o acesso a partir de páginas do navegador. A verificação de origem não é autenticação: um cliente local que não seja um navegador pode enviar esse cabeçalho por conta própria.
1. Validação de entrada
O protocolo aceita exatamente hash. JSON, caminhos, mensagens binárias e outros textos recebem um
erro sem iniciar a leitura de um arquivo. O nome do arquivo vem somente do código do servidor. Os
erros enviados à página usam mensagens fixas em vez de caminhos do sistema de arquivos ou stack
traces.
Para ver a restrição de leitura do Deno, pare o servidor e remova ,./sample.txt da permissão de leitura.
Reinicie e clique no botão: a página carrega, mas a requisição informa
Read permission for sample.txt was denied.. Restaure a permissão
antes de continuar. Remover a permissão de rede impede que o servidor sequer comece a escutar.
2. Limitação de taxa
Este exemplo permite um único cálculo de hash ativo entre todas as conexões. Uma segunda requisição durante essa operação recebe Server is busy. Try again.. O navegador também desabilita o botão durante a espera, então cliques repetidos não conseguem enfileirar trabalho.
Um limite de concorrência não é um limite de taxa. Um cliente pode enviar outra requisição assim que um hash terminar, e o servidor não limita as conexões. Um serviço público precisaria de autenticação, autorização e limites de requisições e de conexões, além dessas verificações.
3. Limites de tamanho de mensagem
O comando aceito tem apenas quatro bytes ASCII, e o conteúdo do arquivo nunca trafega pelo socket. No entanto, a rejeição de outros comandos acontece depois que a mensagem já chegou. Isso não limita o buffering de WebSocket de entrada. A API de WebSocket não tem backpressure, então o limite de leitura do arquivo e a flag de ocupado não tornam este servidor adequado para tráfego não confiável.
Decida se você precisa de WebSockets
Para um único checksum, uma requisição HTTP pode retornar o mesmo resultado com menos gerenciamento de conexão. WebSockets se tornam úteis quando uma página já conectada precisa de uma sequência de mensagens de status ou de resultados. Este pequeno exemplo demonstra essa troca sem adicionar uploads, seleção arbitrária de arquivos nem um serviço de tarefas em segundo plano.
