basic buzzer working with websocket comms

This commit is contained in:
2025-05-11 16:16:43 -06:00
parent 0fd244f3d4
commit 3bb58b5719
17 changed files with 1136 additions and 82 deletions
+3
View File
@@ -23,6 +23,9 @@ mist = ">= 4.0.7 and < 5.0.0"
argv = ">= 1.0.2 and < 2.0.0"
gleam_erlang = ">= 0.34.0 and < 1.0.0"
gleam_http = ">= 4.0.0 and < 5.0.0"
gleam_otp = ">= 0.16.1 and < 1.0.0"
gleam_crypto = ">= 1.5.0 and < 2.0.0"
gleam_json = ">= 2.3.0 and < 3.0.0"
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
+3
View File
@@ -34,8 +34,11 @@ packages = [
[requirements]
argv = { version = ">= 1.0.2 and < 2.0.0" }
gleam_crypto = { version = ">= 1.5.0 and < 2.0.0" }
gleam_erlang = { version = ">= 0.34.0 and < 1.0.0" }
gleam_http = { version = ">= 4.0.0 and < 5.0.0" }
gleam_json = { version = ">= 2.3.0 and < 3.0.0" }
gleam_otp = { version = ">= 0.16.1 and < 1.0.0" }
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
glint = { version = ">= 1.2.1 and < 2.0.0" }
+130 -46
View File
@@ -1,67 +1,47 @@
import app/web
import gleam/bit_array
import gleam/bytes_tree
import gleam/crypto
import gleam/dynamic/decode
import gleam/erlang/process
import gleam/float
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/option.{None}
import gleam/io
import gleam/json
import gleam/list
import gleam/option.{None, Some}
import gleam/otp/actor
import gleam/result
import gleam/string
import mist.{type Connection, type ResponseData}
pub fn handle_request(
request: Request(Connection),
req: Request(Connection),
ctx: web.Context,
) -> Response(ResponseData) {
case request.path_segments(request) {
[] -> serve_frontend()
["static", "frontend.wasm"] -> serve_wasm(ctx)
io.println("Got request: " <> req.path)
case request.path_segments(req) {
[] -> serve_wasm(ctx, "index.html", "text/html")
["static", "frontend.js"] ->
serve_wasm(ctx, "frontend.js", "text/javascript")
["static", "frontend_bg.wasm"] ->
serve_wasm(ctx, "frontend_bg.wasm", "application/wasm")
["ws"] -> serve_websocket(ctx, req)
_ -> response.set_body(response.new(404), mist.Bytes(bytes_tree.new()))
}
}
const game_body = "
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<title>TITLE</title>
<style>
html,
body,
canvas {
margin: 0px;
padding: 0px;
width: 100%;
height: 100%;
overflow: hidden;
position: absolute;
background: black;
z-index: 0;
}
</style>
</head>
<body>
<canvas id=\"glcanvas\" tabindex='1'></canvas>
<!-- Minified and statically hosted version of https://github.com/not-fl3/macroquad/blob/master/js/mq_js_bundle.js -->
<script src=\"https://not-fl3.github.io/miniquad-samples/mq_js_bundle.js\"></script>
<script>load(\"static/frontend.wasm\");</script>
</body>
</html>
"
fn serve_frontend() -> Response(ResponseData) {
response.new(200)
|> response.set_body(mist.Bytes(bytes_tree.from_string(game_body)))
|> response.set_header("content-type", "text/html")
}
fn serve_wasm(ctx: web.Context) -> Response(ResponseData) {
let wasm_path = string.join([ctx.config.static_path, "frontend.wasm"], "/")
fn serve_wasm(
ctx: web.Context,
file: String,
mimetype: String,
) -> Response(ResponseData) {
let wasm_path = string.join([ctx.config.static_path, file], "/")
mist.send_file(wasm_path, offset: 0, limit: None)
|> result.map(fn(file) {
response.new(200)
|> response.prepend_header("content-type", "application/wasm")
|> response.prepend_header("content-type", mimetype)
|> response.set_body(file)
})
|> result.lazy_unwrap(fn() {
@@ -69,3 +49,107 @@ fn serve_wasm(ctx: web.Context) -> Response(ResponseData) {
|> response.set_body(mist.Bytes(bytes_tree.new()))
})
}
fn serve_websocket(
ctx: web.Context,
req: Request(Connection),
) -> Response(ResponseData) {
mist.websocket(
request: req,
on_init: fn(_conn) { #(ctx, Some(ctx.selector)) },
on_close: fn(_state) { io.println("goodbye!") },
handler: handle_ws_message,
)
}
fn decode_error_to_string(decode_err: decode.DecodeError) -> String {
"Path: "
<> string.join(decode_err.path, ".")
<> " Expected: "
<> decode_err.expected
<> " Got: "
<> decode_err.found
}
fn decode_errors_to_string(decode_errs: List(decode.DecodeError)) -> String {
string.join(list.map(decode_errs, decode_error_to_string), "\n")
}
fn handle_ws_message(state, conn, message) {
io.println("Got WS message")
case message {
mist.Text("ping") -> {
io.println("Got Ping")
let assert Ok(_) = mist.send_text_frame(conn, "pong")
actor.continue(state)
}
mist.Text(msg) -> {
echo msg
case json.parse(msg, web.client_request_decoder()) {
Error(err) -> {
let err_msg = case err {
json.UnableToDecode(decode_err) ->
"Decode Error: " <> decode_errors_to_string(decode_err)
json.UnexpectedByte(bad_byte) -> "Unexpeted byte: " <> bad_byte
json.UnexpectedEndOfInput -> "Reached EoF"
json.UnexpectedFormat(_) -> "Unexpected format"
json.UnexpectedSequence(seq_msg) ->
"Unexpected Sequence: " <> seq_msg
}
io.println("Could not parse client message: " <> err_msg)
}
Ok(req) -> {
let resp = handle_client_msg(state, req)
echo resp
let resp_str =
web.encode_game_response(resp)
|> json.to_string
let assert Ok(_) = mist.send_text_frame(conn, resp_str)
Nil
}
}
actor.continue(state)
}
mist.Binary(_) -> {
actor.continue(state)
}
mist.Custom(_) -> {
actor.continue(state)
}
mist.Closed | mist.Shutdown -> actor.Stop(process.Normal)
}
}
fn handle_client_msg(
_ctx: web.Context,
req: web.ClientRequest,
) -> web.GameResponse {
case req.msg {
web.BuzzIn(time) -> {
io.println("Got buzz in @ " <> float.to_string(time))
web.AckBuzzer
}
web.Register(username, gamecode) -> {
let secret = crypto.strong_random_bytes(16)
let token =
crypto.new_hasher(crypto.Sha512)
|> crypto.hash_chunk(secret)
|> crypto.hash_chunk(bit_array.from_string(username))
|> crypto.hash_chunk(bit_array.from_string(gamecode))
|> crypto.digest
let token_hash =
crypto.new_hasher(crypto.Sha512)
|> crypto.hash_chunk(token)
|> crypto.digest
io.println("New token: " <> bit_array.base64_encode(token_hash, True))
let token = bit_array.base64_encode(token, True)
web.JoinResponse(token, username)
}
}
}
+64 -1
View File
@@ -1,7 +1,70 @@
import config
import gleam/dynamic/decode
import gleam/erlang/process
import gleam/json
import session
import storail
pub type ClientMessage {
BuzzIn(time: Float)
Register(game_code: String, username: String)
}
fn client_message_decoder() -> decode.Decoder(ClientMessage) {
use variant <- decode.field("type", decode.string)
case variant {
"BuzzIn" -> {
use time <- decode.field("time", decode.float)
decode.success(BuzzIn(time:))
}
"Register" -> {
use game_code <- decode.field("game_code", decode.string)
use username <- decode.field("username", decode.string)
decode.success(Register(game_code:, username:))
}
_ -> decode.failure(BuzzIn(time: 0.0), "ClientMessage")
}
}
pub type ClientRequest {
ClientRequest(token: String, msg: ClientMessage)
}
pub fn client_request_decoder() -> decode.Decoder(ClientRequest) {
use token <- decode.field("token", decode.string)
use msg <- decode.field("msg", client_message_decoder())
decode.success(ClientRequest(token:, msg:))
}
pub type GameResponse {
JoinResponse(username: String, token: String)
AckBuzzer
ResetBuzzer
UpdatePointTotal(score: Int)
}
pub fn encode_game_response(game_response: GameResponse) -> json.Json {
case game_response {
JoinResponse(..) ->
json.object([
#("type", json.string("JoinResponse")),
#("username", json.string(game_response.username)),
#("token", json.string(game_response.token)),
])
AckBuzzer -> json.object([#("type", json.string("AckBuzzer"))])
ResetBuzzer -> json.object([#("type", json.string("ResetBuzzer"))])
UpdatePointTotal(..) ->
json.object([
#("type", json.string("UpdatePointTotal")),
#("score", json.int(game_response.score)),
])
}
}
pub type Context {
Context(config: config.Config, sessions: storail.Collection(session.Session))
Context(
config: config.Config,
sessions: storail.Collection(session.Session),
selector: process.Selector(ClientRequest),
)
}
+5 -1
View File
@@ -49,7 +49,11 @@ pub fn run_server() -> glint.Command(Result(Nil, Error)) {
let storail_cfg = storail.Config(cfg.storage_path)
let ctx =
web.Context(config: cfg, sessions: setup_session_collection(storail_cfg))
web.Context(
config: cfg,
sessions: setup_session_collection(storail_cfg),
selector: process.new_selector(),
)
let handler = router.handle_request(_, ctx)