add client receiving base

This commit is contained in:
2025-05-04 14:17:20 +02:00
parent fbbaabb04b
commit 194fd2adea
9 changed files with 228 additions and 2 deletions

View File

@@ -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__

View File

@@ -7,6 +7,6 @@
<link href="css/style.css" rel="stylesheet">
</head>
<body>
<script src="script.js"></script>
</body>
</html>

View File

@@ -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 $@

View File

@@ -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<void> {
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<Blob> {
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<Uint8Array> {
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<PythagorasIncomingMessage> {
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);
}
}

View File

@@ -0,0 +1,8 @@
import { PythagorasClient } from "./pythagoras_client";
async function main(): Promise<void> {
const conn = new PythagorasClient("/ws");
console.log(await conn.recv());
}
main();

22
client/tools.test.ts Normal file
View File

@@ -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");
});

13
client/tools.ts Normal file
View File

@@ -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);
}

76
client/ws.ts Normal file
View File

@@ -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<void> {
if (this.is_closed) { throw new Error("connetion was already closed"); }
if (this.is_connected) { return; }
await new Promise<void>((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<Blob | null> {
const queued = this.messages_received.shift();
if (queued !== undefined) { return Promise.resolve(queued); }
if (this.is_closed) { return Promise.resolve(null); }
return new Promise<Blob | null>((resolver) => {
this.callbacks_receive.push(resolver);
});
}
}

View File

@@ -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")