diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d70e367 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "go.lintFlags": [ + "should not use dot imports" + ] +} \ No newline at end of file diff --git a/cmd/shiritori/main.go b/cmd/shiritori/main.go new file mode 100644 index 0000000..7e71134 --- /dev/null +++ b/cmd/shiritori/main.go @@ -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)) +} diff --git a/static/index.html b/cmd/shiritori/static/index.html similarity index 92% rename from static/index.html rename to cmd/shiritori/static/index.html index 323bde4..b32b3a0 100644 --- a/static/index.html +++ b/cmd/shiritori/static/index.html @@ -18,8 +18,7 @@
diff --git a/static/style.css b/cmd/shiritori/static/style.css similarity index 100% rename from static/style.css rename to cmd/shiritori/static/style.css diff --git a/go.mod b/go.mod index bea319a..c3320da 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module ElnuDev/shiritori-go +module git.elnu.com/ElnuDev/shiritori-go go 1.20 \ No newline at end of file diff --git a/httputils/event.go b/httputils/event.go new file mode 100644 index 0000000..5279df1 --- /dev/null +++ b/httputils/event.go @@ -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) +} diff --git a/httputils/handler.go b/httputils/handler.go new file mode 100644 index 0000000..b9a2907 --- /dev/null +++ b/httputils/handler.go @@ -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) + } +} diff --git a/main.go b/main.go deleted file mode 100644 index 5decbab..0000000 --- a/main.go +++ /dev/null @@ -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("
%s
", 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)) -} diff --git a/shiritori/api/events.go b/shiritori/api/events.go new file mode 100644 index 0000000..3cea08b --- /dev/null +++ b/shiritori/api/events.go @@ -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...) + } + } + }) +} diff --git a/shiritori/api/submit.go b/shiritori/api/submit.go new file mode 100644 index 0000000..0d46aee --- /dev/null +++ b/shiritori/api/submit.go @@ -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}, + ) +} diff --git a/shiritori/clientset.go b/shiritori/clientset.go new file mode 100644 index 0000000..27e7428 --- /dev/null +++ b/shiritori/clientset.go @@ -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("
%s
", 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) +}