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)
+}