From 194fd2adea5344c72ae129a89cc0899a47447d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mekina?= Date: Sun, 4 May 2025 14:17:20 +0200 Subject: [PATCH] add client receiving base --- Makefile | 3 ++ client/index.html | 2 +- client/make.mk | 17 ++++++++ client/pythagoras_client.ts | 80 +++++++++++++++++++++++++++++++++++++ client/script.ts | 8 ++++ client/tools.test.ts | 22 ++++++++++ client/tools.ts | 13 ++++++ client/ws.ts | 76 +++++++++++++++++++++++++++++++++++ main.py | 9 ++++- 9 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 client/pythagoras_client.ts create mode 100644 client/tools.test.ts create mode 100644 client/tools.ts create mode 100644 client/ws.ts diff --git a/Makefile b/Makefile index c97fea2..a3e899d 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,9 @@ build: $(CLIENT_TARGETS) .PHONY: pack pack: pythagoras.tar.xz +.PHONY: test +test: client_test + .PHONY: clean clean: client_clean rm -rf __pycache__ diff --git a/client/index.html b/client/index.html index 554c142..1609fe9 100644 --- a/client/index.html +++ b/client/index.html @@ -7,6 +7,6 @@ - + diff --git a/client/make.mk b/client/make.mk index d40bcaa..04dd216 100644 --- a/client/make.mk +++ b/client/make.mk @@ -10,9 +10,24 @@ client_clean: rm -rf static rm -rf client/node_modules +.PHONY: client_test +client_test: + bun test client + client/node_modules: cd client && bun install +# specific files +static/script.js: \ + client/script.ts \ + client/ws.ts \ + client/pythagoras_client.ts \ + client/tools.ts \ + client/node_modules + @mkdir -p $(@D) + bun build $< --minify --outfile $@ + +# generic pages static/%.html: client/%.html client/node_modules @mkdir -p $(@D) cat $< | \ @@ -24,10 +39,12 @@ static/%.html: client/%.html client/node_modules --remove-comments \ --remove-redundant-attributes > $@ +# generic styles static/%.css: client/%.scss client/node_modules @mkdir -p $(@D) bun run --cwd client sass $(notdir $<) --style compressed > $@ +# generic scripts static/%.js: client/%.ts client/node_modules @mkdir -p $(@D) bun build $< --minify --outfile $@ diff --git a/client/pythagoras_client.ts b/client/pythagoras_client.ts new file mode 100644 index 0000000..e565ca7 --- /dev/null +++ b/client/pythagoras_client.ts @@ -0,0 +1,80 @@ +import { uint_bytes_to_num, utf8_decode } from "./tools"; +import { WSClient } from "./ws"; + +enum PythagorasIncomingMessageType { + SubUpdateCur, + SubFinishCur, + SelectedMessage, +}; + +type PythagorasIncomingMessage = ( + +{ + type: PythagorasIncomingMessageType.SubFinishCur | PythagorasIncomingMessageType.SubUpdateCur, + text: string, +} | + +{ + type: PythagorasIncomingMessageType.SelectedMessage, + message: string, +} + +); + +export class PythagorasClient { + private sock: WSClient | null; + private addr: string; + + private buf: Uint8Array; + + private static max_recv_retry: number = 10; + + public constructor(addr: string) { + this.sock = null; + this.addr = addr; + this.buf = new Uint8Array(0); + } + + private async reconnect(): Promise { + this.sock = new WSClient(this.addr); + await this.sock.wait_for_connection(); + } + + /** + * Force receive from the underlying `WSClient`. + * This will try to receive (or reconnect) up to `PythagorasClient.max_recv_retry` times. + */ + private async recv_inner(): Promise { + if (this.sock === null) { await this.reconnect(); } + for (let i = 0; i < PythagorasClient.max_recv_retry; ++i) { + const received = await this.sock!.receive(); + if (received !== null) { return received; } + await this.reconnect(); + } + throw new Error("max reconnection attempts reached"); + } + + /** + * Receive `target` bytes from the underlying stream (or leftovers from previous reads). + */ + private async recv_length(target: number): Promise { + if (target == 0) { return new Uint8Array(0); } + while (this.buf.length < target) { + const received = await (await this.recv_inner()).bytes(); + const merged = new Uint8Array(this.buf.length + received.length); + merged.set(this.buf); + merged.set(received, this.buf.length); + this.buf = merged; + } + const total = this.buf; + this.buf = new Uint8Array(total.length - target); + this.buf.set(total.slice(target)); + return total.slice(0, target); + } + + public async recv(): Promise { + const advertised_length = uint_bytes_to_num(await this.recv_length(4)); + const payload = utf8_decode(await this.recv_length(advertised_length)); + return JSON.parse(payload); + } +} diff --git a/client/script.ts b/client/script.ts index e69de29..40a835d 100644 --- a/client/script.ts +++ b/client/script.ts @@ -0,0 +1,8 @@ +import { PythagorasClient } from "./pythagoras_client"; + +async function main(): Promise { + const conn = new PythagorasClient("/ws"); + console.log(await conn.recv()); +} + +main(); diff --git a/client/tools.test.ts b/client/tools.test.ts new file mode 100644 index 0000000..dd004e2 --- /dev/null +++ b/client/tools.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { uint_bytes_to_num, utf8_decode } from "./tools"; + +test("parse_uint - 0xFF", () => { + expect(uint_bytes_to_num(new Uint8Array([0x0, 0x0, 0x0, 0xFF]))).toBe(0xFF); +}); + +test("parse_uint - 0xFF00", () => { + expect(uint_bytes_to_num(new Uint8Array([0x0, 0x0, 0xFF, 0x0]))).toBe(0xFF00); +}); + +test("parse_uint - 0xFF0000", () => { + expect(uint_bytes_to_num(new Uint8Array([0x0, 0xFF, 0x0, 0x0]))).toBe(0xFF0000); +}); + +test("parse_uint - 0xFF000000", () => { + expect(uint_bytes_to_num(new Uint8Array([0xFF, 0x0, 0x0, 0x0]))).toBe(0xFF000000); +}); + +test("decode_utf8 - \"test\"", () => { + expect(utf8_decode(new Uint8Array([0x74, 0x65, 0x73, 0x74]))).toBe("test"); +}); diff --git a/client/tools.ts b/client/tools.ts new file mode 100644 index 0000000..9c40ea6 --- /dev/null +++ b/client/tools.ts @@ -0,0 +1,13 @@ +/** + * Convert big-endian u32 to native JavaScript number. + */ +export function uint_bytes_to_num(value: Uint8Array): number { + if (value.length != 4) { throw new Error("can't convert non 4-byte integer"); } + const data_view = new DataView(value.buffer); + return data_view.getUint32(0, false); +} + +export function utf8_decode(value: Uint8Array): string { + const decoder = new TextDecoder("utf-8"); + return decoder.decode(value); +} diff --git a/client/ws.ts b/client/ws.ts new file mode 100644 index 0000000..f822112 --- /dev/null +++ b/client/ws.ts @@ -0,0 +1,76 @@ +export class WSClient { + private is_connected: boolean; + private is_closed: boolean; + private sock: WebSocket; + + private messages_received: Blob[]; + + // callbacks + private callbacks_connected: (() => void)[]; + private callbacks_receive: ((data: Blob | null) => void)[]; + + public constructor(addr: string) { + this.is_connected = false; + this.is_closed = false; + this.messages_received = []; + this.callbacks_connected = []; + this.callbacks_receive = []; + + this.sock = new WebSocket(addr); + this.sock.binaryType = "blob"; + this.sock.addEventListener("open", (_) => { + this.is_connected = true; + for (const callback of this.callbacks_connected) { callback(); } + this.callbacks_connected = []; + }); + this.sock.addEventListener("message", (e) => { + const callback = this.callbacks_receive.shift(); + if (callback === undefined) { this.messages_received.push(e.data); return; } + callback(e.data); + }); + this.sock.addEventListener("error", (_) => { this.end(); }); + this.sock.addEventListener("close", (_) => { this.end(); }); + } + + public async wait_for_connection(): Promise { + if (this.is_closed) { throw new Error("connetion was already closed"); } + if (this.is_connected) { return; } + await new Promise((resolver) => { + this.callbacks_connected.push(resolver); + }); + } + + private end(): void { + this.is_connected = false; + this.is_closed = true; + for (const callback of this.callbacks_connected) { callback(); } + this.callbacks_connected = []; + for (const callback of this.callbacks_receive) { callback(null); } + this.callbacks_receive = []; + } + + /** + * @returns `true` if the send call was placed successfully, `false` if the client is not + * active anymore + */ + public send(message: string | ArrayBufferLike | Blob | ArrayBufferView): boolean { + if (!this.is_connected) { return false; } + this.sock.send(message); + return true; + } + + /** + * Waits for a new incoming message or connection close. + * + * @returns a `Blob` if there was an incoming message, `null` if the connection closed before + * a message could be received + */ + public receive(): Promise { + const queued = this.messages_received.shift(); + if (queued !== undefined) { return Promise.resolve(queued); } + if (this.is_closed) { return Promise.resolve(null); } + return new Promise((resolver) => { + this.callbacks_receive.push(resolver); + }); + } +} diff --git a/main.py b/main.py index b688da1..f5e2290 100644 --- a/main.py +++ b/main.py @@ -85,10 +85,17 @@ def read_file(filepath: str) -> str: @dataclass class StaticFiles: index_html: str = read_file("static/index.html") + script_js: str = read_file("static/script.js") @app.get("/presentation/") async def presentation_index(_: Request): - return HTMLResponse(status_code=200, content=StaticFiles.index_html) + return HTMLResponse(status_code=200, content=StaticFiles.index_html, media_type="text/html") + +@app.get("/presentation/script.js") +async def presentation_script(_: Request): + return HTMLResponse( + status_code=200, content=StaticFiles.script_js, media_type="text/javascript" + ) # Endpoints @app.post("/control")