add client receiving base
This commit is contained in:
@@ -7,6 +7,6 @@
|
||||
<link href="css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -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 $@
|
||||
|
80
client/pythagoras_client.ts
Normal file
80
client/pythagoras_client.ts
Normal 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);
|
||||
}
|
||||
}
|
@@ -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
22
client/tools.test.ts
Normal 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
13
client/tools.ts
Normal 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
76
client/ws.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user