basic version working

This commit is contained in:
Jurn Wubben 2025-11-05 22:36:41 +01:00
commit 39f6e99abe
11 changed files with 782 additions and 0 deletions

64
.helix/languages.toml Normal file
View file

@ -0,0 +1,64 @@
[[language]]
name = "javascript"
language-id = "javascript"
scope = "source.js"
injection-regex = "^(js|javascript)$"
file-types = ["js", "jsx", "mjs"]
shebangs = ["deno", "node"]
roots = ["deno.json", "package.json", "tsconfig.json"]
comment-token = "//"
indent = { tab-width = 2, unit = " " }
grammar = "javascript"
language-servers = ["deno-lsp"]
[[language]]
name = "jsx"
language-id = "javascriptreact"
scope = "source.jsx"
injection-regex = "jsx"
file-types = ["jsx"]
shebangs = ["deno", "node"]
roots = ["deno.json", "package.json", "tsconfig.json"]
comment-token = "//"
indent = { tab-width = 2, unit = " " }
grammar = "javascript"
language-servers = ["deno-lsp"]
[[language]]
name = "typescript"
language-id = "typescript"
scope = "source.ts"
injection-regex = "^(ts|typescript)$"
file-types = ["ts"]
shebangs = ["deno", "node"]
roots = ["deno.json", "package.json", "tsconfig.json"]
comment-token = "//"
indent = { tab-width = 2, unit = " " }
grammar = "typescript"
language-servers = ["deno-lsp"]
[[language]]
name = "tsx"
language-id = "typescriptreact"
scope = "source.tsx"
injection-regex = "^(tsx)$" # |typescript
file-types = ["tsx"]
shebangs = ["deno", "node"]
roots = ["deno.json", "package.json", "tsconfig.json"]
comment-token = "//"
indent = { tab-width = 2, unit = " " }
grammar = "typescript"
language-servers = ["deno-lsp"]
[language-server.deno-lsp]
command = "deno"
args = ["lsp"]
config = { enable = true, lint = true, unstable = true }
[language-server.emmet-lsp]
command = "emmet-language-server"
args = ["--stdio"]
[[language]]
name = "html"
language-servers = ["emmet-lsp", "vscode-html-language-server"]

11
deno.json Normal file
View file

@ -0,0 +1,11 @@
{
"tasks": {
"dev": "deno run -A --watch src/index.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@1",
"@std/async": "jsr:@std/async@^1.0.15",
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@std/http": "jsr:@std/http@^1.0.21"
}
}

85
deno.lock generated Normal file
View file

@ -0,0 +1,85 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@1": "1.0.15",
"jsr:@std/async@^1.0.15": "1.0.15",
"jsr:@std/cli@^1.0.23": "1.0.23",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@^1.0.8": "1.0.8",
"jsr:@std/fs@^1.0.19": "1.0.19",
"jsr:@std/html@^1.0.5": "1.0.5",
"jsr:@std/http@^1.0.21": "1.0.21",
"jsr:@std/internal@^1.0.10": "1.0.12",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/net@^1.0.6": "1.0.6",
"jsr:@std/path@^1.1.2": "1.1.2",
"jsr:@std/streams@^1.0.13": "1.0.13"
},
"jsr": {
"@std/assert@1.0.15": {
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b",
"dependencies": [
"jsr:@std/internal@^1.0.12"
]
},
"@std/async@1.0.15": {
"integrity": "55d1d9d04f99403fe5730ab16bdcc3c47f658a6bf054cafb38a50f046238116e"
},
"@std/cli@1.0.23": {
"integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@1.0.8": {
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
},
"@std/fs@1.0.19": {
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06"
},
"@std/html@1.0.5": {
"integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e"
},
"@std/http@1.0.21": {
"integrity": "abb5c747651ee6e3ea6139858fd9b1810d2c97f53a5e6722f3b6d27a6d263edc",
"dependencies": [
"jsr:@std/cli",
"jsr:@std/encoding",
"jsr:@std/fmt",
"jsr:@std/fs",
"jsr:@std/html",
"jsr:@std/media-types",
"jsr:@std/net",
"jsr:@std/path",
"jsr:@std/streams"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/net@1.0.6": {
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
},
"@std/path@1.1.2": {
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
"dependencies": [
"jsr:@std/internal@^1.0.10"
]
},
"@std/streams@1.0.13": {
"integrity": "772d208cd0d3e5dac7c1d9e6cdb25842846d136eea4a41a62e44ed4ab0c8dd9e"
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@1",
"jsr:@std/async@^1.0.15",
"jsr:@std/encoding@^1.0.10",
"jsr:@std/http@^1.0.21"
]
}
}

2
log Normal file
View file

@ -0,0 +1,2 @@
2025-11-05T13:45:22.658 helix_term::application [WARN] Ignoring a client/registerCapability request because dynamic capability registration is not enabled. Please report this upstream to the language server
2025-11-05T13:45:25.229 helix_lsp::transport [ERROR] Tried sending response into a closed channel (id=Num(5)), original request likely timed out

87
src/auth.ts Normal file
View file

@ -0,0 +1,87 @@
import {
buildCommand,
buildError,
Command,
parseCommand,
} from "./commandbuilder.ts";
import { Device } from "./device.ts";
function toHex(data: Uint8Array): string {
return Array.from(data).map((b) => ("00" + b.toString(16)).slice(-2)).join(
"",
);
}
async function generateHMAC(key: string, message: string) {
const encoder = new TextEncoder();
const keyData = encoder.encode(key);
const messageData = encoder.encode(message);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: { name: "SHA-256" } },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
cryptoKey,
messageData,
);
return toHex(new Uint8Array(signature));
}
export class Authentication {
_socket: WebSocket;
_encoder: TextEncoder;
nonce: string;
device: Device | undefined;
signature: string | undefined;
constructor(socket: WebSocket) {
this._socket = socket;
this._encoder = new TextEncoder();
this.nonce = toHex(crypto.getRandomValues(new Uint8Array(32)));
}
async authenticate(message: Command) { // ugh, js doesn't have async constructors, hence the seeprate init function.
if (
message.d == undefined || !("id" in message.d) ||
typeof (message.d.id) !== "string" || !(message.d.id in Device.devices)
) {
this._socket.send(buildError(1, "Invalid packet. Missing ID"));
this._socket.close();
return;
}
this._socket.addEventListener("message", (msg) => this._validate(msg));
this.device = Device.devices[message.d.id];
this.signature = await generateHMAC(this.device.password, this.nonce);
this._socket.send(
buildCommand("auth_nonce", { nonce: this.nonce }),
);
}
_validate(msg: MessageEvent<any>) {
if (!this.device || !this.signature) return;
const parsed = parseCommand(msg.data);
if (
parsed === null || parsed.c !== "auth_validate" ||
parsed.d === undefined || !("signature" in parsed.d)
) return;
if (this.signature !== parsed.d.signature) {
this._socket.send(buildError(2, "Invalid signature."));
this._socket.close;
return;
}
this._socket.send(buildCommand("auth_ok"));
this.device?.connect(this._socket);
this._socket.removeEventListener("message", this._validate);
}
}

14
src/commandbuilder.ts Normal file
View file

@ -0,0 +1,14 @@
export type Command = {
c: string,
d: object | undefined
}
export const buildCommand = (command: string, data?: any) => JSON.stringify({c: command, d: data})
export const buildError = (error: number, info?: string) => JSON.stringify({e: error, info})
export const parseCommand = (command: string): Command | null => {
const parsed = JSON.parse(command);
if (!("c" in parsed && ("d" in parsed && typeof(parsed.d) == "object") || !("d" in parsed)))
return null
return parsed as Command
}

65
src/device.ts Normal file
View file

@ -0,0 +1,65 @@
import { buildCommand, buildError, parseCommand } from "./commandbuilder.ts";
type Votes = [number, number, number, number, number]
const EMPTY_VOTES: Votes = Array(5).fill(0) as Votes;
export class Device {
static devices: {[x: string]: Device} = {}
password: string;
id: string;
_socket: WebSocket | undefined;
latestVotes: Votes | undefined
sessionTimeout: number | undefined;
get connected() {
return this._socket !== undefined;
}
constructor(password: string, id: string) {
this.password = password;
this.id = id;
if (id in Device.devices) throw new Error("id already taken")
Device.devices[id] = this;
}
connect(socket: WebSocket) {
if (this._socket !== undefined) {
this._socket.send(buildError(4, "Logged in at other place."))
this._socket.close();
}
this._socket = socket;
this._socket.addEventListener("close", _ => this._close())
this._socket.addEventListener("error", _ => this._close())
}
startSession(text: string) {
if (!this.connected) return
this.latestVotes = structuredClone(EMPTY_VOTES);
this._socket?.send(buildCommand("session_start", {text}))
this._socket?.addEventListener("message", msg => this._handleVote(msg))
this.sessionTimeout = setTimeout(() => this.stopSession, 5 * 60 * 1000);
}
stopSession() {
this._socket?.removeEventListener("message", msg => this._handleVote(msg))
this._socket?.send(buildCommand("session_stop"))
this.sessionTimeout = undefined;
}
_close() {
this._socket = undefined;
this.sessionTimeout = undefined;
clearTimeout(this.sessionTimeout);
}
_handleVote(msg: MessageEvent<any>) {
const parsed = parseCommand(msg.data);
if (parsed == undefined || parsed.c !== "session_vote" || parsed.d == undefined || !("vote" in parsed.d) || typeof(parsed.d.vote) !== "number") return;
if (this.latestVotes === undefined) return;
this.latestVotes[parsed.d.vote - 1] += 1;
}
}

51
src/heartbeat.ts Normal file
View file

@ -0,0 +1,51 @@
import { buildError, parseCommand } from "./commandbuilder.ts";
const HEARTBEAT_INTERVAL = 30_000;
const HEARTBEAT_TIMEOUT = 5_000;
const S_PING = JSON.stringify({ "c": "ping" });
export class WSHeartbeat {
heartbeatTimer: number | undefined;
pongTimer: number | undefined;
socket: WebSocket;
constructor(socket: WebSocket) {
this.socket = socket;
socket.addEventListener("open", (_) => this._setHeartBeat());
socket.addEventListener("close", _ => this._clearTimers());
socket.addEventListener("error", _ => this._clearTimers());
socket.addEventListener("message", (msg) => this._onMessage(msg));
}
_setPong(state: boolean) {
if (state) {
clearTimeout(this.pongTimer);
return;
}
this.socket.send(S_PING);
this.pongTimer = setTimeout(() => {
this.socket.send(buildError(0, "Pong missed."));
this.socket.close();
}, HEARTBEAT_TIMEOUT);
}
_setHeartBeat() {
this._setPong(false);
this.heartbeatTimer = setInterval(() => {
this._setPong(false);
}, HEARTBEAT_INTERVAL);
}
_clearTimers() {
clearTimeout(this.pongTimer);
clearTimeout(this.heartbeatTimer);
}
_onMessage(msg: MessageEvent<any>) {
const text = msg.data;
const parsed = parseCommand(text);
if (parsed === null || parsed.c !== "pong") return;
this._setPong(true);
}
}

142
src/index.ts Normal file
View file

@ -0,0 +1,142 @@
import { serveFile } from "@std/http/file-server";
import { WSHeartbeat } from "./heartbeat.ts";
import { Registration } from "./registration.ts";
import { parseCommand } from "./commandbuilder.ts";
import { Authentication } from "./auth.ts";
import { Device } from "./device.ts";
// deno-lint-ignore no-explicit-any
async function wsStartHandler(this: WebSocket, msg: MessageEvent<any>) {
const data = parseCommand(msg.data);
if (data === null) return;
switch (data.c as string) {
case "reg_start":
{
new Registration(this);
this.removeEventListener("message", wsStartHandler);
break;
}
case "auth_start": {
await new Authentication(this).authenticate(data);
this.removeEventListener("message", wsStartHandler);
break;
}
default:
break;
}
}
function handlePrompt(prompt: string) {
let tokens = prompt.match(/(?:[^\s"]+|"[^"]*")+/g) || [] as string[];
tokens = tokens.map((v) => v.replaceAll(/"([^"]*)"+/g, "$1"));
if (tokens.length === 0) return;
switch (tokens[0]) {
case "pin": {
const pin = +tokens[1];
if (pin === 0 || isNaN(pin)) return;
console.log(Registration.validate(pin) ? "great success" : "invalid pin");
break;
}
case "devices": {
console.debug(Device.devices);
break;
}
case "startsession": {
if (tokens.length < 3) return;
if (!(tokens[1] in Device.devices)) {
console.log("invalid uuid");
return;
}
const device = Device.devices[tokens[1]];
if (!device.connected) {
console.log("Device not connected.");
return;
}
device.startSession(tokens[2]);
console.log("great success");
break;
}
case "stopsession": {
if (tokens.length < 2) return;
if (!(tokens[1] in Device.devices)) {
console.log("invalid uuid");
return;
}
const device = Device.devices[tokens[1]];
if (!device.connected) {
console.log("Device not connected.");
return;
}
device.stopSession();
console.log("great success");
break;
}
case "votes": {
if (tokens.length < 2) return;
if (!(tokens[1] in Device.devices)) {
console.log("invalid uuid");
return;
}
const device = Device.devices[tokens[1]];
if (!device.connected) {
console.log("Device not connected.");
return;
}
console.log(device.latestVotes);
}
}
}
const WS_ROUTE = new URLPattern({ pathname: "/ws" });
Deno.serve((req) => {
if (WS_ROUTE.exec(req.url) === null) {
return serveFile(req, "./static/index.html");
}
if (req.headers.get("upgrade") != "websocket") {
return new Response(null, { status: 426 });
}
const { socket, response } = Deno.upgradeWebSocket(req);
new WSHeartbeat(socket);
socket.addEventListener("message", wsStartHandler);
return response;
});
const decoder = new TextDecoder();
console.info(`
Commands:
- pin <int>
used to activate device.
- devices
list devices
- startsession <uuid> "<text>"
starts a voting session
- stopsession <uuid>
stops a voting session
- votes <uuid>
grabs the latest votes.
`);
Deno.stdout.write(Uint8Array.from([62, 32])); // '> '
while (true) { // ja dit is scuffed, nee het werkt niet goed, nee ik ga er niks aan doen.
for await (const chunk of Deno.stdin.readable) {
const prompt = decoder.decode(chunk).trim();
if (prompt !== "") handlePrompt(prompt);
Deno.stdout.write(Uint8Array.from([62, 32])); // '> '
}
}

53
src/registration.ts Normal file
View file

@ -0,0 +1,53 @@
import { buildCommand } from "./commandbuilder.ts";
import { Device } from "./device.ts";
import { encodeBase64 } from "@std/encoding/base64";
type ResolveFunc = (
value: PromiseLike<Device | undefined> | Device | undefined,
) => void;
export class Registration {
static _registrations: Registration[] = [];
_socket: WebSocket;
_pin: number;
constructor(socket: WebSocket) {
this._socket = socket;
this._pin = Math.floor(Math.random() * 10 ** 6); // Normally you'd want to check if this is unique or not.
this._socket.send(
buildCommand("reg_pin", { pin: this._pin }),
);
this._socket.addEventListener("close", () => this.delete());
this._socket.addEventListener("error", () => this.delete());
Registration._registrations.push(this);
}
validate(pin: number): boolean {
if (pin !== this._pin) return false;
const password = encodeBase64(crypto.getRandomValues(new Uint8Array(128))); // relatively secure
const id = crypto.randomUUID();
this._socket.send(buildCommand("reg_ok", { password, id }));
this._socket.send(buildCommand("restart"));
new Device(password, id);
Registration._registrations = Registration._registrations.filter((v) =>
v._pin !== this._pin
);
this._socket.close()
return true;
}
delete() {
Registration._registrations = Registration._registrations.filter(v => v._pin === this._pin);
}
static validate(pin: number): boolean {
return Registration._registrations.find((v) => v.validate(pin)) !==
undefined;
}
}

208
static/index.html Normal file
View file

@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body {
width: 100%;
height: 100%;
margin: 2em;
display: grid;
grid-template-columns: max-content auto;
gap: 2em;
}
.labelgrid {
display: grid;
grid-template-columns: min-content auto;
column-gap: 1em;
}
.buttongrid {
display: flex;
flex-direction: row;
flex-basis: 0;
width: 100%;
padding-top: 5px;
}
.buttongrid>* {
width: 0;
flex-grow: 1;
}
#text {
display: none;
}
#votebuttons {
display: none;
}
</style>
</head>
<body>
<div>
<div class="labelgrid">
<label for="id"> ID</label>
<input id="id" />
<label for="passcode"> passcode</label>
<input id="passcode" />
<label for="pin"> pin</label>
<input id="pin" />
<label for="auth"> authenticated</label>
<input id="auth" type=checkbox onclick="return false" />
</div>
<div class="buttongrid" id="authbuttons">
<button onclick="startRegistration()">registrate</button>
<button onclick="startAuthentication()">authenticate</button>
</div>
<div class="buttongrid" id="votebuttons">
<button onclick="vote(1)">1</button>
<button onclick="vote(2)">2</button>
<button onclick="vote(3)">3</button>
<button onclick="vote(4)">4</button>
<button onclick="vote(5)">5</button>
</div>
</div>
<div>
<h1 id="text"></h1>
<div id="logger"></div>
</div>
<script>
function preventDefault(e) {alert(); e.preventDefault()}
function toHex(data) {
return Array.from(data).map((b) => ("00" + b.toString(16)).slice(-2)).join(
"",
);
}
async function generateHMAC(key, message) {
const encoder = new TextEncoder();
const keyData = encoder.encode(key);
const messageData = encoder.encode(message);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyData,
{name: "HMAC", hash: {name: "SHA-256"}},
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
cryptoKey,
messageData,
);
return toHex(new Uint8Array(signature));
}
function log(text) {
logger.innerHTML += text + "<br>";
}
function disableAuthBtns() {
document.querySelectorAll("#authbuttons").forEach(v => v.disabled = false);
}
const id = document.querySelector("#id");
const pass = document.querySelector("#passcode");
const pin = document.querySelector("#pin");
const logger = document.querySelector("#logger")
const text = document.querySelector("#text")
const votebuttons = document.querySelector("#votebuttons")
const auth = document.querySelector("#auth")
function websock() {
let socket = new WebSocket("ws://localhost:8000/ws");
socket.sendln = (...e) => {
log("device: " + e.join("\n"));
socket.send(...e)
}
socket.addEventListener("message", async (event) => {
log("server: " + event.data);
let data = JSON.parse(event.data);
switch (data.c) {
case "ping": {
socket.sendln(JSON.stringify({c: "pong"}));
break
}
case "reg_pin": {
pin.value = data.d.pin;
break
}
case "reg_ok": {
id.value = data.d.id;
pass.value = data.d.password;
socket.close();
pin.value = "";
log("### closing and reopening socket");
websock()
break
}
case "auth_nonce": {
const signature = await generateHMAC(pass.value, data.d.nonce)
console.log(pass.value, data.value, signature)
socket.sendln(JSON.stringify({c: "auth_validate", d: {signature}}));
break
}
case "auth_ok": {
auth.checked = true;
disableAuthBtns()
break;
}
case "session_start": {
text.style.display = "block";
votebuttons.style.display = "flex";
text.innerText = data.d.text;
break;
}
case "session_stop": {
text.style.display = "none";
votebuttons.style.display = "none";
text.innerText = "";
break;
}
}
});
socket.addEventListener("close", () => {
auth.checked = false;
disableAuthBtns()
window.startRegistration = undefined;
window.startAuthentication = undefined;
text.innerText = ""
})
socket.addEventListener("open", () => {
window.startRegistration = () => {
socket.sendln(JSON.stringify({c: "reg_start"}))
}
window.startAuthentication = () => {
socket.sendln(JSON.stringify({c: "auth_start", d: {id: id.value}}))
}
window.vote = (num) => {
socket.sendln(JSON.stringify({c: "session_vote", d: {vote: num}}))
}
})
}
websock()
</script>
</body>
</html>