Rearrange repo as monorepo

main
Elnu 10 months ago
parent 2a59a96493
commit 848175efde

@ -0,0 +1,5 @@
{
"go.lintFlags": [
"should not use dot imports"
]
}

@ -0,0 +1,17 @@
package main
import (
"log"
"net/http"
. "git.elnu.com/ElnuDev/shiritori-go/shiritori"
"git.elnu.com/ElnuDev/shiritori-go/shiritori/api"
)
func main() {
clients := NewClientSet()
http.Handle("/", http.FileServer(http.Dir("static")))
http.HandleFunc("/api/events", api.GenerateApiEvents(&clients))
http.HandleFunc("/api/submit", api.GenerateApiSubmit(&clients))
log.Fatal(http.ListenAndServe(":3333", nil))
}

@ -18,8 +18,7 @@
</div>
<form
hx-post="/api/submit"
hx-swap="innerHTML"
hx-target="#feedback"
hx-swap="none"
hx-on::after-request="this.querySelector('input[name=word]').value = ''">
<input id="shiritori-input" type="text" name="word">
</form>

@ -1,3 +1,3 @@
module ElnuDev/shiritori-go
module git.elnu.com/ElnuDev/shiritori-go
go 1.20

@ -0,0 +1,12 @@
package httputils
import "fmt"
type SseEvent struct {
EventName string
Data string
}
func (event SseEvent) Encode() string {
return fmt.Sprintf("event: %s\ndata: %s\n\n", event.EventName, event.Data)
}

@ -0,0 +1,50 @@
package httputils
import (
"fmt"
"net/http"
"text/template"
)
type Handler = func(http.ResponseWriter, *http.Request)
func GenerateHandler(
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) {
for _, method := range methods {
if method == r.Method {
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 Handler) Handler {
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("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
handler(w, r)
}
}

@ -1,169 +0,0 @@
package main
import (
"fmt"
"html/template"
"log"
"net/http"
)
type handler = func(http.ResponseWriter, *http.Request)
func generateHandler(
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) {
for _, method := range methods {
if method == r.Method {
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 handler) handler {
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("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
handler(w, r)
}
}
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) playerCount() int {
return len(clients.clients)
}
func (clients clientSet) playerCountEvent(offset int) event {
playerCount := clients.playerCount() + 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) {
sendEvent := func(events ...event) {
for _, event := range events {
fmt.Fprint(w, event.encode())
}
w.(http.Flusher).Flush()
}
sendEvent(clients.playerCountEvent(1))
ctx := r.Context()
channel := make(client)
clients.connect(channel)
outer:
for {
select {
case <-ctx.Done():
clients.disconnect(channel)
break outer
case events := <-channel:
sendEvent(events...)
}
}
})
}
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() {
http.Handle("/", http.FileServer(http.Dir("static")))
http.HandleFunc("/api/events", generateWordBroadcaster())
http.HandleFunc("/api/submit", generateWordHandler())
log.Fatal(http.ListenAndServe(":3333", nil))
}

@ -0,0 +1,34 @@
package api
import (
"fmt"
"net/http"
"git.elnu.com/ElnuDev/shiritori-go/httputils"
. "git.elnu.com/ElnuDev/shiritori-go/shiritori"
)
func GenerateApiEvents(clients *ClientSet) httputils.Handler {
return httputils.GenerateSseHandler(func(w http.ResponseWriter, r *http.Request) {
sendEvent := func(events ...httputils.SseEvent) {
for _, event := range events {
fmt.Fprint(w, event.Encode())
}
w.(http.Flusher).Flush()
}
sendEvent(clients.PlayerCountEvent(1))
ctx := r.Context()
channel := make(Client)
clients.Connect(channel)
outer:
for {
select {
case <-ctx.Done():
clients.Disconnect(channel)
break outer
case events := <-channel:
sendEvent(events...)
}
}
})
}

@ -0,0 +1,20 @@
package api
import (
"net/http"
"git.elnu.com/ElnuDev/shiritori-go/httputils"
. "git.elnu.com/ElnuDev/shiritori-go/shiritori"
)
func GenerateApiSubmit(clients *ClientSet) httputils.Handler {
return httputils.GenerateHandler(
"",
func(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(0)
clients.BroadcastWord(r.FormValue("word"))
},
nil,
[]string{http.MethodPost},
)
}

@ -0,0 +1,76 @@
package shiritori
import (
"fmt"
"git.elnu.com/ElnuDev/shiritori-go/httputils"
)
type Client = chan []httputils.SseEvent
const EventWord = "word"
const EventPlayerCount = "players"
const EventNavigate = "navigate"
type ClientSet struct {
clients map[Client]bool
}
func NewClientSet() ClientSet {
return ClientSet{make(map[Client]bool)}
}
func (clients ClientSet) Broadcast(events ...httputils.SseEvent) {
for client := range clients.clients {
client <- events
}
}
func (clients ClientSet) PlayerCount() int {
return len(clients.clients)
}
func (clients ClientSet) PlayerCountEvent(offset int) httputils.SseEvent {
playerCount := clients.PlayerCount() + 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 httputils.SseEvent{EventName: EventPlayerCount, Data: data}
}
func (clients ClientSet) BroadcastPlayerCount(offset int) {
clients.Broadcast(clients.PlayerCountEvent(offset))
}
func (clients ClientSet) BroadcastWord(word string) {
clients.Broadcast(
httputils.SseEvent{
EventName: EventWord,
Data: fmt.Sprintf("<div>%s</div>", word),
},
httputils.SseEvent{
EventName: EventNavigate,
Data: 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)
}