Real-time file processing with Deno and WebSockets
Click a button in a browser, have Deno hash a file on your machine, and receive its byte count and
SHA-256 checksum over WebSocket. This example reads one fixed file, sample.txt, and accepts up to
1 MiB. The browser sends a command and receives the result; it does not upload the file.
The walkthrough uses Deno 2.9.6 and Chromium on Linux, with Bash for the terminal commands. Use the
Deno installation guide if you need
the runtime, and check your version with deno --version. There are no packages to install.
Why Deno?
Deno supplies the HTTP server, WebSocket upgrade, file APIs, and Web Crypto used here. Its permission flags let us grant application reads of two files and network access to one loopback address and port. The example needs no write, subprocess, or environment access.
Setting up a WebSocket server
From a directory of your choice, paste this into Bash. It creates a new project and a three-byte
file containing abc, with no trailing newline. If deno-checksum already exists, creation fails
without overwriting it. The parentheses keep your terminal in the parent directory.
(
mkdir deno-checksum &&
cd deno-checksum &&
printf 'abc' > sample.txt
)
Save the following as deno-checksum/server.ts. It serves the browser page at / and upgrades
requests at /ws using Deno’s WebSocket API.
Only the exact text command hash starts work. No message can choose a file path.
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() can return fewer bytes
than requested, so the loop continues until EOF or the buffer is full. using closes the file when
the function leaves, including on failure. Keep sample.txt as an ordinary local file and leave it
unchanged during a request: this is not a filesystem snapshot.
The read buffer holds at most 1 MiB plus one byte, even if the file grows. That bound matters because
crypto.subtle.digest() takes its input in memory,
without streaming it. It does not mean the entire server uses only 1 MiB of memory.
Client implementation
Save this as deno-checksum/index.html. The button stays disabled until the socket opens and while
a request is pending. Results replace the previous display; nothing is saved to disk.
<!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>
Running the server
From the same parent directory, run:
(
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 avoids inheriting an enclosing project’s Deno configuration. --no-prompt makes
missing permissions fail instead of asking for broader access. The read grant covers the page loaded
at startup and the sample opened for each request. Network access is scoped to 127.0.0.1:8000;
the server also binds to that address explicitly.
Open http://127.0.0.1:8000/ and select Hash file. Use that exact
address, rather than opening the HTML file directly or using localhost: the WebSocket handshake
checks the page’s origin. When the status becomes Done., the
result should be:
{
"type": "result",
"bytes": 3,
"sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
}
In another terminal in the parent directory, independently check the same bytes with Linux’s
sha256sum:
sha256sum deno-checksum/sample.txt
Its first field should match sha256. A different value can mean your editor added a newline.
Change the file after the request finishes and select Hash file
again to hash its new contents. Empty files and binary data work too; the .txt name does not cause
text decoding. Stop the server with Ctrl+C when finished.
If startup reports an occupied address, choose a free port and change both const port and the
--allow-net port before trying again. Missing index.html stops startup. Missing sample.txt
instead produces sample.txt was not found. in the browser; restore
the file and click again. A file larger than the limit produces
sample.txt exceeds 1 MiB. without returning a checksum.
Understanding the WebSocket lifecycle
The client sends only while readyState is WebSocket.OPEN. The server first sends started,
then either result or error. A started operation is not evidence that the file was read
successfully, and the status messages are not percentage progress updates.
If the browser disconnects during hashing, the bounded operation finishes and the server drops its
reply once the socket is no longer open. The finally block releases the busy flag for subsequent
requests. Reload the page to reconnect and request a fresh checksum. There is no automatic retry,
job history, or recovery of a lost result.
Security best practices
Keep this demo local. Binding to loopback and checking the HTTP host and WebSocket origin narrow access from browser pages. The origin check is not authentication: a local non-browser client can send that header itself.
1. Input validation
The protocol accepts exactly hash. JSON, paths, binary messages, and other text receive an error
without starting a file read. The filename comes only from the server code. Errors sent to the page
use fixed messages rather than filesystem paths or stack traces.
To see Deno’s read restriction, stop the server and remove ,./sample.txt from the read grant.
Restart and click the button: the page loads, but the request reports
Read permission for sample.txt was denied.. Restore the grant
before continuing. Removing the network grant prevents the server from listening at all.
2. Rate limiting
This example allows one active hash across all connections. A second request during that operation receives Server is busy. Try again.. The browser also disables its button while waiting, so repeated clicks cannot queue work.
A concurrency limit is not a rate limit. A client can send another request as soon as a hash finishes, and the server does not cap connections. A public service would need authentication, authorization, and request and connection limits in addition to these checks.
3. Message size limits
The accepted command is only four ASCII bytes, and file contents never travel over the socket. However, rejecting other commands happens after a message has arrived. It does not bound incoming WebSocket buffering. The WebSocket API lacks backpressure, so the file read limit and busy flag do not make this a server for untrusted traffic.
Decide whether you need WebSockets
For a single checksum, an HTTP request can return the same result with less connection management. WebSockets become useful when an already connected page needs successive status messages or results. This small example demonstrates that exchange without adding uploads, arbitrary file selection, or a background job service.
