Related to an older conversation, I made this.
It runs in Deno, uses no JS client side (some JS is used for UX, but it is not critical), is super lightweight. If using https, wouldn't leak what's sent/received to eavesdroppers (but of course server can read messages).
I have no idea why I did this, I guess just to see if I could. Now that it exists, I may expand it with some minimal additional effort some day:
With this, it'd be a fairly usable chat app, albeit without any strong privacy guarantees. I don't think it's possible to make it secure without client-side JS though.
It runs in Deno, uses no JS client side (some JS is used for UX, but it is not critical), is super lightweight. If using https, wouldn't leak what's sent/received to eavesdroppers (but of course server can read messages).
I have no idea why I did this, I guess just to see if I could. Now that it exists, I may expand it with some minimal additional effort some day:
- long automatically generated room hashes that get assigned automatically if you don't enter one, for semi-private rooms.
- ability to rename other people in the room so you can recognize them (but people don't name themselves -- naming is "phonebook-style")
With this, it'd be a fairly usable chat app, albeit without any strong privacy guarantees. I don't think it's possible to make it secure without client-side JS though.
#!/usr/bin/env -S deno run --watch --allow-net
/// <reference lib="dom" />
const POST_URL = "/form";
const CHAT_URL = "/messages";
const Page = (title: string, content: string) =>
`<!DOCTYPE html>` +
`<html lang="en">` +
`<head>` +
`<meta charset="UTF-8"> <title>${title}</title>` +
`<meta name="viewport" content="width=device-width, initial-scale=1.0">` +
`<link rel="stylesheet" href="/style.css">` +
`</head><body>` +
`${content}` +
`<script src="/browser.js"></script>` +
`</body>` +
`</html>`;
const FORM = Page(
"form",
`<form action="${POST_URL}" method="POST" enctype="multipart/form-data">` +
`<input type="text" placeholder="message" required name="message" autocomplete="off" autofocus>` +
`<input type="submit" value="send">` +
`</form>`,
);
const CHAT = Page(
"chat",
`<ul>`,
);
const MAIN = Page(
"catspeak",
`<iframe class="chat" src="${CHAT_URL}" frameborder="0"></iframe>` +
`<iframe class="form" src="${POST_URL}" frameborder="0"></iframe>`,
);
const css =
`body { background-color: #f0f0f0; font-family: Arial, sans-serif;}` +
`iframe.chat {width: 100%; height: calc(100vh - 5rem);}` +
`iframe.form {width: 100%; height: 5rem;}` +
`form{display: flex; align-items: center; justify-content: space-between;}` +
`input[type="text"] {width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px;}` +
`input[type="submit"] { padding: 10px 20px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;}` +
`input[type="submit"]:hover { background-color: #45a049;}` +
`time{ display: inline-block; margin-left: 10px; color: #999; font-size: 0.8rem;}` +
`ul{display: grid; list-style-type: none; padding: 0; margin: 0;}` +
`li{display: flex; align-items: center; justify-content: start; gap: 1rem;}`;
const browserJs = `(${(() => {
document.querySelectorAll("form").forEach((form) => {
const input = form.getElementsByTagName("input")[0];
form.addEventListener("submit", (event) => {
event.preventDefault();
const body = new FormData(form);
const url = form.getAttribute("action") || "/";
const method = form.getAttribute("method") || "POST";
fetch(url, { method, body });
input.value = "";
input.focus();
});
});
})})()`;
const responseHTML = (content: string) =>
new Response(content, { headers: { "content-type": "text/html" } });
type Client = {
id: number;
send(message: string): void;
};
const makeClient = () => {
const id = clients.size;
let controller: ReadableStreamDefaultController | null = null;
const stream = new ReadableStream({
start(initialController) {
controller = initialController;
controller.enqueue(new TextEncoder().encode(CHAT));
},
cancel() {
id > -1 && clients.delete(id);
},
});
const send = (message: string) => {
controller && controller.enqueue(new TextEncoder().encode(message));
};
const client = {
id,
send,
stream,
};
clients.set(client.id, client);
return client;
};
const clients = new Map<number, Client>();
const sendMessage = (message: string) => {
const element = `<li><time>${
new Date().toISOString()
}</time><span>${message}</span></li>`;
for (const client of clients.values()) {
client.send(element);
}
};
Deno.serve((req) => {
const url = new URL(req.url);
if (url.pathname === "/") {
return responseHTML(MAIN);
}
if (url.pathname === "/style.css") {
return new Response(css, { headers: { "content-type": "text/css" } });
}
if (url.pathname === "/browser.js") {
return new Response(browserJs, {
headers: { "content-type": "text/javascript" },
});
}
if (url.pathname === POST_URL) {
if (
req.method === "POST" && req.headers.get("Content-Length") != null &&
req.headers.get("content-type")?.startsWith("multipart/form-data")
) {
req.formData().then((data) =>
data.has("message") && typeof data.get("message") === "string" &&
data.get("message") != "" && sendMessage(data.get("message")! as string)
);
return new Response(null, {
status: 302,
headers: { location: POST_URL },
});
}
return responseHTML(FORM);
}
if (url.pathname === CHAT_URL) {
const client = makeClient();
return new Response(client.stream, {
headers: {
"content-type": "text/html",
"transfer-encoding": "chunked",
"x-content-type-options": "nosniff",
},
});
}
if (url.pathname === "/favicon.ico") {
return new Response(null, {
status: 204,
});
}
return new Response("Not Found", { status: 404 });
});
I know what you're thinking