Elnu 2 years ago
parent fcc9d9f5ef
commit a78edaed81

@ -5,22 +5,43 @@ import (
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"time"
) )
type handler = func(http.ResponseWriter, *http.Request) type handler = func(http.ResponseWriter, *http.Request)
func generateHandler(file string, handler func(), data func() any) handler { func generateHandler(
tmpl := template.Must(template.ParseFiles(fmt.Sprintf("templates/%s", file))) file string,
handler func(http.ResponseWriter, *http.Request),
data func(http.ResponseWriter, *http.Request) any,
methods []string,
) handler {
var tmpl *template.Template
if file != "" {
tmpl = template.Must(template.ParseFiles(fmt.Sprintf("templates/%s", file)))
}
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
handler() for _, method := range methods {
w.Header().Set("Content-Type", "text/html; charset=utf-8") if method == r.Method {
tmpl.Execute(w, data()) goto ok
}
}
w.WriteHeader(http.StatusMethodNotAllowed)
return
ok:
handler(w, r)
if tmpl != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data(w, r))
}
} }
} }
func generateSseHandler(handler func(http.ResponseWriter, *http.Request)) handler { func generateSseHandler(handler handler) handler {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
@ -29,25 +50,116 @@ func generateSseHandler(handler func(http.ResponseWriter, *http.Request)) handle
} }
} }
func generateCounter() handler { type client = chan []event
type clientSet struct {
clients map[client]bool
}
func newClientSet() clientSet {
return clientSet{make(map[client]bool)}
}
func (clients clientSet) broadcast(events ...event) {
for client := range clients.clients {
client <- events
}
}
func (clients clientSet) playerCountEvent(offset int) event {
playerCount := len(clients.clients) + offset
var data string
if playerCount == 0 {
data = "No players online."
} else {
data = fmt.Sprintf("%d player%s online.", playerCount, map[bool]string{true: "", false: "s"}[playerCount == 1])
}
return event{eventPlayerCount, data}
}
func (clients clientSet) broadcastPlayerCount(offset int) {
clients.broadcast(clients.playerCountEvent(offset))
}
func (clients clientSet) broadcastWord(word string) {
clients.broadcast(
event{eventWord, fmt.Sprintf("<div>%s</div>", word)},
event{eventNavigate, fmt.Sprintf("https://jisho.org/search/%s", word)},
)
}
func (clients clientSet) connect(client client) {
// Channels are blocking.
// Client initialization is waiting for connect() to complete.
// If we try to braodcast to it before connect() is done,
// the channel handling logic wouldn't have started yet,
// and broadcast() would be freeze.
// Therefore, we must broadcast an offset player count first,
// then actually add the client.
clients.broadcastPlayerCount(1)
clients.clients[client] = true
}
func (clients clientSet) disconnect(client client) {
delete(clients.clients, client)
clients.broadcastPlayerCount(0)
}
var clients = newClientSet()
type event struct {
eventName string
data string
}
func (event event) encode() string {
return fmt.Sprintf("event: %s\ndata: %s\n\n", event.eventName, event.data)
}
const eventWord = "word"
const eventPlayerCount = "players"
const eventNavigate = "navigate"
func generateWordBroadcaster() handler {
return generateSseHandler(func(w http.ResponseWriter, r *http.Request) { return generateSseHandler(func(w http.ResponseWriter, r *http.Request) {
tick := time.Tick(500 * time.Millisecond) sendEvent := func(events ...event) {
for _, event := range events {
fmt.Fprint(w, event.encode())
}
w.(http.Flusher).Flush()
}
sendEvent(clients.playerCountEvent(1))
ctx := r.Context() ctx := r.Context()
channel := make(client)
clients.connect(channel)
outer: outer:
for i := 0; ; i++ { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
clients.disconnect(channel)
break outer break outer
case <-tick: case events := <-channel:
fmt.Fprintf(w, "event: count\ndata: <div>%d</div>\n\n", i) sendEvent(events...)
w.(http.Flusher).Flush()
} }
} }
}) })
} }
func generateWordHandler() handler {
return generateHandler(
"",
func(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(0)
clients.broadcastWord(r.FormValue("word"))
},
nil,
[]string{http.MethodPost},
)
}
func main() { func main() {
http.Handle("/", http.FileServer(http.Dir("static"))) http.Handle("/", http.FileServer(http.Dir("static")))
http.HandleFunc("/api/counter", generateCounter()) http.HandleFunc("/api/events", generateWordBroadcaster())
http.HandleFunc("/api/submit", generateWordHandler())
log.Fatal(http.ListenAndServe(":3333", nil)) log.Fatal(http.ListenAndServe(":3333", nil))
} }

@ -4,9 +4,39 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shiritori</title> <title>Shiritori</title>
<link rel="stylesheet" href="style.css">
<script src="https://unpkg.com/htmx.org@1.9.3"></script> <script src="https://unpkg.com/htmx.org@1.9.3"></script>
</head> </head>
<body> <body hx-sse="connect:/api/events">
<div hx-sse="connect:/api/counter swap:count" hx-swap="beforebegin"></div> <div id="content">
<div id="split">
<div>
<div id="shiritori"
hx-sse="swap:word"
hx-swap="beforeend"
hx-on::sse-message="this.scrollTop = this.scrollHeight">
</div>
<form
hx-post="/api/submit"
hx-swap="innerHTML"
hx-target="#feedback"
hx-on::after-request="this.querySelector('input[name=word]').value = ''">
<input id="shiritori-input" type="text" name="word">
</form>
</div>
<iframe
hx-sse="swap:navigate"
hx-on::sse-message="this.src = event.detail.data">
</iframe>
</div>
<p id="shiritori-players"
hx-sse="swap:players"
></p>
<div style="display: none">
<button onclick="changeRoom('lobby')">Back to lobby</button>
<div id="feedback"></div>
Change room: <input id="room-input" autocomplete="off">
</div>
</div>
</body> </body>
</html> </html>

@ -0,0 +1,92 @@
* {
font-family: sans-serif;
color: ghostwhite;
}
body {
background: slategrey;
font-size: 1.75em;
display: flex;
height: 100vh;
align-items: center;
justify-content: center;
text-align: center;
}
div#content {
width: 32em;
max-width: 100%;
}
div#shiritori p {
margin: 0;
animation-duration: 1s;
animation-name: slidein;
animation-fill-mode: forwards;
opacity: 0;
}
@keyframes slidein {
from {
transform: translateX(-32px);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}
div#shiritori {
padding: 2px;
height: 24em;
overflow-y: scroll;
}
div#shiritori, input#shiritori-input, #split > :last-child {
background: rgba(0, 0, 0, 0.25);
border: 1px solid;
border-radius: 4px;
box-sizing: border-box;
}
div#shiritori, input#shiritori-input {
padding: 0.25em;
}
input {
font-size: 1em;
background: rgba(0, 0, 0, 0.25);
border: none;
padding: 0.25em;
}
input#shiritori-input {
width: 100%;
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
input#shiritori-input:focus {
outline: none;
background: rgba(0, 0, 0, 0.125);
}
div#shiritori {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
#split {
display: flex;
gap: 0.5em;
flex-direction: row;
}
#split > * {
width: 16em;
}
a {
text-decoration: none;
}
a:hover {
border-bottom: 2px solid;
}
button {
background: rgba(0, 0, 0, 0.25);
font-size: inherit;
cursor: pointer;
padding: 1em;
border: 0;
}