Procesamiento de archivos en tiempo real con Deno y WebSockets
Haz clic en un botón del navegador para que Deno calcule el hash de un archivo de tu equipo y te
envíe su cantidad de bytes y su suma de verificación SHA-256 por WebSocket. Este ejemplo lee un
único archivo fijo, sample.txt, y acepta hasta 1 MiB. El navegador envía un
comando y recibe el resultado; no sube el archivo.
El tutorial usa Deno 2.9.6 y Chromium en Linux, con Bash para los comandos de terminal. Consulta la
guía de instalación de Deno si necesitas el entorno de ejecución,
y comprueba tu versión con deno --version. No hay paquetes que instalar.
¿Por qué Deno?
Deno proporciona el servidor HTTP, el cambio de protocolo a WebSocket, las API de archivos y Web Crypto que se usan aquí. Sus opciones de permisos nos permiten conceder a la aplicación acceso de lectura a dos archivos y acceso de red a una única dirección de bucle local y un puerto. El ejemplo no necesita permisos de escritura, de ejecución de subprocesos ni de acceso al entorno.
Configuración de un servidor WebSocket
Desde un directorio de tu elección, pega lo siguiente en Bash. Crea un proyecto nuevo y un archivo
de tres bytes que contiene abc, sin salto de línea al final. Si
deno-checksum ya existe, la creación falla sin sobrescribirlo. Los paréntesis
mantienen tu terminal en el directorio padre.
(
mkdir deno-checksum &&
cd deno-checksum &&
printf 'abc' > sample.txt
)
Guarda lo siguiente como deno-checksum/server.ts. Sirve la página del navegador en
/ y cambia a WebSocket el protocolo de las solicitudes en
/ws mediante la API de WebSocket de Deno.
Solo el comando de texto exacto hash inicia el procesamiento. Ningún
mensaje puede elegir una ruta de archivo.
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() puede devolver menos bytes de los
solicitados, por lo que el bucle continúa hasta llegar al final del archivo (EOF) o llenar el búfer.
using cierra el archivo cuando termina la función, incluso si falla.
Mantén sample.txt como un archivo local normal y no lo modifiques durante una
solicitud: esto no es una instantánea del sistema de archivos.
El búfer de lectura contiene como máximo 1 MiB más un byte, incluso si el archivo crece. Ese límite
importa porque crypto.subtle.digest() recibe su entrada en memoria,
sin procesarla en streaming. No significa que todo el servidor use solo 1 MiB de memoria.
Implementación del cliente
Guarda esto como deno-checksum/index.html. El botón permanece deshabilitado hasta que se abre
el socket y mientras haya una solicitud pendiente. Los resultados reemplazan lo que se mostraba
antes; no se guarda nada en 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>
Ejecución del servidor
Desde el mismo directorio padre, ejecuta:
(
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 heredar la configuración de Deno de un proyecto contenedor.
--no-prompt hace que la falta de permisos provoque un fallo en lugar de solicitar
un acceso más amplio. El permiso de lectura abarca la página que se carga al iniciar y el archivo
de ejemplo que se abre en cada solicitud. El acceso de red se limita a
127.0.0.1:8000; el servidor también se vincula explícitamente a esa dirección.
Abre http://127.0.0.1:8000/ y selecciona Hash file.
Usa esa dirección exacta, en lugar de abrir el archivo HTML directamente o usar
localhost: la negociación de WebSocket comprueba el origen de la página.
Cuando el estado cambie a Done., el
resultado debería ser:
{
"type": "result",
"bytes": 3,
"sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
}
En otra terminal, desde el directorio padre, comprueba de forma independiente los mismos bytes
con sha256sum de Linux:
sha256sum deno-checksum/sample.txt
Su primer campo debería coincidir con sha256. Un valor distinto puede
significar que tu editor añadió un salto de línea. Modifica el archivo una vez que termine la
solicitud y selecciona Hash file de nuevo
para calcular el hash del contenido nuevo. También funciona con archivos vacíos y datos binarios;
el nombre .txt no provoca la decodificación del texto. Detén el servidor
con Ctrl+C cuando termines.
Si al iniciar se informa de una dirección ocupada, elige un puerto libre y cambia tanto
const port como el puerto de --allow-net antes de volver a
intentarlo. La falta de index.html impide el inicio. En cambio, la falta de
sample.txt produce sample.txt was not found.
en el navegador; restaura el archivo y vuelve a hacer clic. Un archivo que supera el límite produce
sample.txt exceeds 1 MiB. sin devolver una suma de verificación.
Cómo funciona el ciclo de vida de WebSocket
El cliente solo envía mensajes mientras readyState sea
WebSocket.OPEN. El servidor envía primero started y después
result o error. Que una operación haya comenzado no
demuestra que el archivo se haya leído correctamente, y los mensajes de estado no son
actualizaciones del porcentaje de progreso.
Si el navegador se desconecta durante el cálculo del hash, la operación de lectura limitada
termina y el servidor descarta su respuesta cuando el socket deja de estar abierto. El bloque
finally desactiva el indicador de ocupado para las solicitudes posteriores.
Recarga la página para volver a conectarte y solicitar una nueva suma de verificación. No hay
reintentos automáticos, historial de tareas ni recuperación de resultados perdidos.
Buenas prácticas de seguridad
Mantén esta demostración en el entorno local. Vincularse a la dirección de bucle local y comprobar el host HTTP y el origen de WebSocket restringe el acceso desde las páginas del navegador. La comprobación del origen no es autenticación: un cliente local que no sea un navegador puede enviar ese encabezado por su cuenta.
1. Validación de entradas
El protocolo acepta exactamente hash. Los mensajes JSON, las rutas,
los mensajes binarios y otros textos reciben un error sin iniciar la lectura de un archivo. El
nombre del archivo procede únicamente del código del servidor. Los errores enviados a la página
usan mensajes fijos en lugar de rutas del sistema de archivos o trazas de pila.
Para ver la restricción de lectura de Deno, detén el servidor y elimina
,./sample.txt del permiso de lectura. Reinicia y haz clic en el botón: la página
se carga, pero la solicitud informa de
Read permission for sample.txt was denied.. Restaura el permiso antes de
continuar. Eliminar el permiso de red impide por completo que el servidor escuche conexiones.
2. Limitación de la tasa de solicitudes
Este ejemplo permite un único cálculo de hash activo entre todas las conexiones. Una segunda solicitud durante esa operación recibe Server is busy. Try again.. El navegador también deshabilita su botón mientras espera, por lo que los clics repetidos no pueden poner tareas en cola.
Un límite de concurrencia no es un límite de la tasa de solicitudes. Un cliente puede enviar otra solicitud en cuanto termina un cálculo de hash, y el servidor no limita la cantidad de conexiones. Un servicio público necesitaría autenticación, autorización y límites de solicitudes y conexiones, además de estas comprobaciones.
3. Límites de tamaño de los mensajes
El comando aceptado ocupa solo cuatro bytes ASCII, y el contenido del archivo nunca viaja por el socket. Sin embargo, el rechazo de otros comandos ocurre después de que llega un mensaje. Esto no limita el almacenamiento en búfer de los mensajes WebSocket entrantes. La API de WebSocket carece de control de contrapresión, por lo que el límite de lectura del archivo y el indicador de ocupado no hacen que este servidor sea apto para tráfico no confiable.
Decide si necesitas WebSockets
Para una sola suma de verificación, una solicitud HTTP puede devolver el mismo resultado con menos gestión de conexiones. WebSockets resulta útil cuando una página ya conectada necesita mensajes de estado o resultados sucesivos. Este pequeño ejemplo demuestra ese intercambio sin añadir subidas, selección arbitraria de archivos ni un servicio de tareas en segundo plano.
