11 Commits

Author SHA1 Message Date
Ryan Hamamura
8573e87bf6 fix: add /assets/ prefix to hashfs paths in prod
All checks were successful
CI / Deploy / test (push) Successful in 13s
CI / Deploy / lint (push) Successful in 24s
CI / Deploy / deploy (push) Successful in 1m27s
2026-03-03 13:37:04 -10:00
67a768ea22 Fix SSE architecture for reliable connections (#13)
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 1m32s
2026-03-03 23:33:13 +00:00
Ryan Hamamura
331c4c8759 docs: add AGENTS.md with coding guidelines for AI agents
All checks were successful
CI / Deploy / test (push) Successful in 15s
CI / Deploy / lint (push) Successful in 28s
CI / Deploy / deploy (push) Successful in 1m28s
Includes build/test commands, code style guidelines, naming conventions,
error handling patterns, and Go/templ/Datastar patterns used in this repo.
2026-03-03 10:53:14 -10:00
f6c5949247 Merge pull request 'Fix connection indicator script duplication on SSE patches' (#12) from fix/connection-indicator-script into main
All checks were successful
CI / Deploy / test (push) Successful in 18s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 1m27s
2026-03-03 20:44:56 +00:00
Ryan Hamamura
d6e64763cc fix: use templ.NewOnceHandle to prevent script duplication on SSE patches
All checks were successful
CI / Deploy / test (pull_request) Successful in 15s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Replace ConnectionIndicatorWithScript wrapper with a single ConnectionIndicator
component that uses templ.NewOnceHandle() to ensure the watcher script is only
rendered once per page, even when the indicator is patched via SSE.
2026-03-03 10:43:23 -10:00
589d1f09e8 Merge pull request 'Refactor connection indicator to patch with timestamp' (#11) from refactor/patch-connection-indicator into main
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 1m22s
2026-03-03 20:32:11 +00:00
Ryan Hamamura
06b3839c3a refactor: patch connection indicator with timestamp
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 26s
CI / Deploy / deploy (pull_request) Has been skipped
Server patches the ConnectionIndicator element with a timestamp on
each heartbeat. Client-side JS checks every second if the timestamp
is stale (>20s) and toggles red/green accordingly.

This properly detects connection loss since the indicator will turn
red if no patches are received.
2026-03-03 10:30:55 -10:00
99f14ca170 Merge pull request 'Add connection status indicator with SSE heartbeat' (#10) from feat/sse-heartbeat into main
All checks were successful
CI / Deploy / test (push) Successful in 15s
CI / Deploy / lint (push) Successful in 27s
CI / Deploy / deploy (push) Successful in 1m23s
2026-03-03 20:15:29 +00:00
Ryan Hamamura
da82f31d46 feat: add connection status indicator with SSE heartbeat
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
- Add ConnectionIndicator component showing green/red dot
- Send lastPing signal every 15 seconds via SSE
- Indicator turns red if no ping received in 20 seconds
- Gives users confidence the live connection is active
2026-03-03 10:05:03 -10:00
ffbff8cca5 Merge pull request 'Simplify chat subscription API' (#9) from refactor/chat-subscribe-messages into main
All checks were successful
CI / Deploy / test (push) Successful in 14s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Successful in 1m25s
2026-03-03 19:54:21 +00:00
Ryan Hamamura
bcb1fa3872 refactor: simplify chat subscription API
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
Room.Subscribe() now returns a channel of parsed Message structs
instead of raw NATS messages. The room handles NATS subscription
and message parsing internally, so callers no longer need to call
Receive() separately.
2026-03-03 09:45:56 -10:00
23 changed files with 3251 additions and 178 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@
!.env.example !.env.example
!LICENSE !LICENSE
!AGENTS.md
!assets/**/* !assets/**/*

253
AGENTS.md Normal file
View File

@@ -0,0 +1,253 @@
# AGENTS.md
Instructions for AI coding agents working in this repository.
## Quick Reference
```bash
# Development
task live # Hot-reload dev server (templ + tailwind + air)
task build # Production build to bin/games
task run # Build and run server
# Quality
task test # Run all tests: go test ./...
task lint # Run linter: golangci-lint run
# Single test
go test -run TestName ./path/to/package
# Code generation
task build:templ # Compile .templ files
task build:styles # Build TailwindCSS
go generate ./... # Run sqlc for DB queries
```
## Workflow Rules
- **Never merge PRs without explicit user approval.** Create the PR, push changes, then wait.
- Always use PRs via `tea` CLI - never push directly to main.
- Write semantic commit messages focusing on "why" not "what".
## Project Structure
```
games/
├── connect4/, snake/ # Game logic packages (pure Go)
├── features/ # Feature modules (handlers, routes, templates)
│ ├── auth/ # Login/register
│ ├── c4game/ # Connect 4 UI
│ ├── snakegame/ # Snake UI
│ ├── lobby/ # Game lobby
│ └── common/ # Shared components, layouts
├── chat/ # Reusable chat room (NATS + persistence)
├── db/ # SQLite, migrations, sqlc queries
├── assets/ # Static files (embedded)
└── config/, logging/, nats/, sessions/, router/ # Infrastructure
```
## Code Style
### Imports
Organize in three groups: stdlib, third-party, local. The linter enforces this.
```go
import (
"context"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
)
```
### Naming Conventions
| Type | Convention | Examples |
|------|------------|----------|
| Files | lowercase, underscores | `config_dev.go`, `handlers.go` |
| HTTP handlers | `Handle` prefix | `HandleGamePage`, `HandleLogin` |
| Constructors | `New` prefix | `NewStore`, `NewRoom` |
| Getters | `Get` prefix | `GetPlayerID`, `GetGame` |
| Setup functions | `Setup` prefix | `SetupRoutes`, `SetupLogger` |
| Types | PascalCase | `Game`, `Player`, `Instance` |
| Status enums | `Status` prefix | `StatusWaitingForPlayer`, `StatusInProgress` |
| Session keys | `Key` prefix | `KeyPlayerID`, `KeyUserID` |
### Error Handling
1. **Wrap errors with context:**
```go
return fmt.Errorf("loading game %s: %w", id, err)
```
2. **Return (result, error) tuples:**
```go
func loadGame(queries *repository.Queries, id string) (*Game, error)
```
3. **Best-effort operations** - use nolint comment:
```go
nc.Publish(subject, nil) //nolint:errcheck // best-effort notification
```
4. **HTTP errors:**
```go
http.Error(w, "game not found", http.StatusNotFound)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
```
### Comments
- Focus on **why**, not **how**. Avoid superfluous comments.
- Package comments at top of primary file:
```go
// Package connect4 implements Connect 4 game logic, state management, and persistence.
package connect4
```
- Function comments for exported functions:
```go
// DropPiece attempts to drop a piece in the given column.
// Returns (row placed, success).
func (g *Game) DropPiece(col, playerColor int) (int, bool)
```
## Go Patterns
### Dependency Injection via Closures
Handlers receive dependencies and return `http.HandlerFunc`:
```go
func HandleGamePage(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// use store, sm here
}
}
```
### Mutex for Concurrent Access
```go
type Store struct {
games map[string]*Instance
gamesMu sync.RWMutex
}
func (s *Store) Get(id string) (*Instance, bool) {
s.gamesMu.RLock()
defer s.gamesMu.RUnlock()
inst, ok := s.games[id]
return inst, ok
}
```
### Build Tags for Environment
```go
//go:build dev
//go:build !dev
```
### Embedded Filesystems
```go
//go:embed assets
var assets embed.FS
//go:embed migrations/*.sql
var MigrationFS embed.FS
```
### Graceful Shutdown
```go
eg, egctx := errgroup.WithContext(ctx)
eg.Go(func() error { return server.ListenAndServe() })
eg.Go(func() error {
<-egctx.Done()
return server.Shutdown(context.Background())
})
return eg.Wait()
```
## Templ + Datastar Patterns
### SSE Connection with Disabled Cancellation
Datastar cancels SSE on user interaction by default. Disable for persistent connections:
```go
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
```
### Prevent Script Duplication on SSE Patches
Use `templ.NewOnceHandle()` for scripts in components that get patched:
```go
var scriptHandle = templ.NewOnceHandle()
templ MyComponent() {
<div id="my-component">...</div>
@scriptHandle.Once() {
@myScript()
}
}
```
### Conditional Classes with templ.KV
```go
class={
"status status-sm",
templ.KV("status-success", isConnected),
templ.KV("status-error", !isConnected),
}
```
### Datastar SSE Responses
```go
sse := datastar.NewSSE(w, r)
sse.MergeFragmentTempl(components.GameBoard(game))
```
## Tech Stack
| Layer | Technology |
|-------|------------|
| Templates | templ (type-safe HTML) |
| Reactivity | Datastar (SSE-driven) |
| CSS | TailwindCSS v4 + daisyUI |
| Router | chi/v5 |
| Sessions | scs/v2 |
| Database | SQLite (modernc.org/sqlite) |
| Migrations | goose |
| SQL codegen | sqlc |
| Pub/sub | Embedded NATS |
| Logging | zerolog |
## Testing
```bash
# All tests
task test
# Single test
go test -run TestDropPiece ./connect4
# With verbose output
go test -v -run TestDropPiece ./connect4
# Test a package
go test ./connect4/...
```
Use `testutil.SetupTestDB()` for tests requiring database access.

5
assets/assets.go Normal file
View File

@@ -0,0 +1,5 @@
// Package assets provides static file serving with build-tag switching
// between live filesystem (dev) and embedded hashfs (prod).
package assets
const DirectoryPath = "assets"

File diff suppressed because one or more lines are too long

22
assets/static_dev.go Normal file
View File

@@ -0,0 +1,22 @@
//go:build dev
package assets
import (
"net/http"
"os"
"github.com/rs/zerolog/log"
)
func Handler() http.Handler {
log.Debug().Str("path", DirectoryPath).Msg("static assets served from filesystem")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
http.StripPrefix("/assets/", http.FileServerFS(os.DirFS(DirectoryPath))).ServeHTTP(w, r)
})
}
func StaticPath(path string) string {
return "/assets/" + path
}

26
assets/static_prod.go Normal file
View File

@@ -0,0 +1,26 @@
//go:build !dev
package assets
import (
"embed"
"net/http"
"github.com/benbjohnson/hashfs"
"github.com/rs/zerolog/log"
)
var (
//go:embed css js
staticFiles embed.FS
staticSys = hashfs.NewFS(staticFiles)
)
func Handler() http.Handler {
log.Debug().Msg("static assets are embedded with hashfs")
return http.StripPrefix("/assets/", hashfs.FileServer(staticSys))
}
func StaticPath(path string) string {
return "/assets/" + staticSys.HashName(path)
}

View File

@@ -76,12 +76,11 @@ func (r *Room) Send(msg Message) {
} }
} }
// Receive processes an incoming NATS message, appending it to the buffer. // receive processes an incoming NATS message, appending it to the buffer.
// Returns the new message and a snapshot of all messages. func (r *Room) receive(data []byte) (Message, bool) {
func (r *Room) Receive(data []byte) (Message, []Message) {
var msg Message var msg Message
if err := json.Unmarshal(data, &msg); err != nil { if err := json.Unmarshal(data, &msg); err != nil {
return msg, nil return msg, false
} }
r.mu.Lock() r.mu.Lock()
@@ -89,11 +88,9 @@ func (r *Room) Receive(data []byte) (Message, []Message) {
if len(r.messages) > maxMessages { if len(r.messages) > maxMessages {
r.messages = r.messages[len(r.messages)-maxMessages:] r.messages = r.messages[len(r.messages)-maxMessages:]
} }
snapshot := make([]Message, len(r.messages))
copy(snapshot, r.messages)
r.mu.Unlock() r.mu.Unlock()
return msg, snapshot return msg, true
} }
// Messages returns a snapshot of the current message buffer. // Messages returns a snapshot of the current message buffer.
@@ -105,15 +102,32 @@ func (r *Room) Messages() []Message {
return snapshot return snapshot
} }
// Subscribe creates a NATS channel subscription for the room's subject. // Subscribe returns a channel of parsed messages and a cleanup function.
// Caller is responsible for unsubscribing. // The room handles NATS subscription internally and buffers messages.
func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) { func (r *Room) Subscribe() (<-chan Message, func()) {
ch := make(chan *nats.Msg, 64) natsCh := make(chan *nats.Msg, 64)
sub, err := r.nc.ChanSubscribe(r.subject, ch) msgCh := make(chan Message, 64)
sub, err := r.nc.ChanSubscribe(r.subject, natsCh)
if err != nil { if err != nil {
return nil, nil, err close(msgCh)
return msgCh, func() {}
} }
return ch, sub, nil
go func() {
for natsMsg := range natsCh {
if msg, ok := r.receive(natsMsg.Data); ok {
msgCh <- msg
}
}
close(msgCh)
}()
cleanup := func() {
_ = sub.Unsubscribe()
}
return msgCh, cleanup
} }
func (r *Room) saveMessage(msg Message) { func (r *Room) saveMessage(msg Message) {

View File

@@ -1,46 +1,23 @@
package c4game package c4game
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/chat" "github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components" chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/connect4" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/c4game/pages" "github.com/ryanhamamura/games/features/c4game/pages"
"github.com/ryanhamamura/games/features/c4game/services"
"github.com/ryanhamamura/games/sessions" "github.com/ryanhamamura/games/sessions"
) )
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors. func HandleGamePage(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
var c4ChatColors = map[int]string{
0: "#4a2a3a", // color 1 stored as slot 0
1: "#2a4545", // color 2 stored as slot 1
}
func c4ChatColor(slot int) string {
if c, ok := c4ChatColors[slot]; ok {
return c
}
return "#666"
}
func c4ChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "c4",
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
Color: c4ChatColor,
}
}
func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -84,16 +61,17 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo
} }
g := gi.GetGame() g := gi.GetGame()
room := chat.NewPersistentRoom(nil, "", queries, gameID) room := svc.ChatRoom(gameID)
if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { if err := pages.GamePage(g, myColor, room.Messages(), svc.ChatConfig(gameID)).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
} }
} }
func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
gi, exists := store.Get(gameID) gi, exists := store.Get(gameID)
@@ -103,58 +81,74 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
} }
playerID := sessions.GetPlayerID(sm, r) playerID := sessions.GetPlayerID(sm, r)
myColor := gi.GetPlayerColor(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression( // Subscribe to game state updates BEFORE creating SSE
datastar.WithBrotli(datastar.WithBrotliLevel(5)), gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
))
chatCfg := c4ChatConfig(gameID)
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
patchAll := func() error {
myColor = gi.GetPlayerColor(playerID)
g := gi.GetGame()
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
}
// Send initial render
if err := patchAll(); err != nil {
return
}
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
defer gameSub.Unsubscribe() //nolint:errcheck defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages // Subscribe to chat messages BEFORE creating SSE
chatCh, chatSub, err := room.Subscribe() chatCfg := svc.ChatConfig(gameID)
if err != nil { room := svc.ChatRoom(gameID)
chatCh, cleanupChat := room.Subscribe()
defer cleanupChat()
// Setup heartbeat BEFORE creating SSE
heartbeat := time.NewTicker(1 * time.Second)
defer heartbeat.Stop()
// NOW create SSE
sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
// Define patch function
patchAll := func() error {
myColor := gi.GetPlayerColor(playerID)
g := gi.GetGame()
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
}
// Send initial state
if err := patchAll(); err != nil {
return return
} }
defer chatSub.Unsubscribe() //nolint:errcheck
ctx := r.Context() // Event loop
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-gameCh: case <-gameCh:
// Drain rapid-fire notifications
drainGame:
for {
select {
case <-gameCh:
default:
break drainGame
}
}
if err := patchAll(); err != nil { if err := patchAll(); err != nil {
return return
} }
case msg := <-chatCh:
chatMsg, _ := room.Receive(msg.Data) case chatMsg := <-chatCh:
err := sse.PatchElementTempl( if err := sse.PatchElementTempl(
chatcomponents.ChatMessage(chatMsg, chatCfg), chatcomponents.ChatMessage(chatMsg, chatCfg),
datastar.WithSelectorID("c4-chat-history"), datastar.WithSelectorID("c4-chat-history"),
datastar.WithModeAppend(), datastar.WithModeAppend(),
) ); err != nil {
if err != nil { return
}
case <-heartbeat.C:
// Heartbeat refreshes game state to keep connection alive
if err := patchAll(); err != nil {
return return
} }
} }
@@ -191,7 +185,7 @@ func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.Handler
} }
} }
func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { func HandleSendChat(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
@@ -238,7 +232,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager
Message: signals.ChatMsg, Message: signals.ChatMsg,
Time: time.Now().UnixMilli(), Time: time.Now().UnixMilli(),
} }
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID) room := svc.ChatRoom(gameID)
room.Send(msg) room.Send(msg)
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)

View File

@@ -25,6 +25,7 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) { templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
<div id="game-content"> <div id="game-content">
@sharedcomponents.LiveClock()
@sharedcomponents.BackToLobby() @sharedcomponents.BackToLobby()
@sharedcomponents.StealthTitle("text-3xl font-bold") @sharedcomponents.StealthTitle("text-3xl font-bold")
@components.PlayerInfo(g, myColor) @components.PlayerInfo(g, myColor)

View File

@@ -4,24 +4,22 @@ package c4game
import ( import (
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/ryanhamamura/games/connect4" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository" "github.com/ryanhamamura/games/features/c4game/services"
) )
func SetupRoutes( func SetupRoutes(
router chi.Router, router chi.Router,
store *connect4.Store, store *connect4.Store,
nc *nats.Conn, svc *services.GameService,
sessions *scs.SessionManager, sessions *scs.SessionManager,
queries *repository.Queries,
) { ) {
router.Route("/games/{id}", func(r chi.Router) { router.Route("/games/{id}", func(r chi.Router) {
r.Get("/", HandleGamePage(store, sessions, queries)) r.Get("/", HandleGamePage(store, svc, sessions))
r.Get("/events", HandleGameEvents(store, nc, sessions, queries)) r.Get("/events", HandleGameEvents(store, svc, sessions))
r.Post("/drop", HandleDropPiece(store, sessions)) r.Post("/drop", HandleDropPiece(store, sessions))
r.Post("/chat", HandleSendChat(store, nc, sessions, queries)) r.Post("/chat", HandleSendChat(store, svc, sessions))
r.Post("/join", HandleSetNickname(store, sessions)) r.Post("/join", HandleSetNickname(store, sessions))
r.Post("/rematch", HandleRematch(store, sessions)) r.Post("/rematch", HandleRematch(store, sessions))
}) })

View File

@@ -0,0 +1,70 @@
// Package services provides the game service layer for Connect 4,
// handling NATS subscriptions and chat room management.
package services
import (
"fmt"
"github.com/nats-io/nats.go"
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
)
// c4ChatColors maps player slot (0-indexed) to CSS background colors.
var c4ChatColors = map[int]string{
0: "#4a2a3a", // Red player
1: "#2a4545", // Yellow player
}
func c4ChatColor(slot int) string {
if c, ok := c4ChatColors[slot]; ok {
return c
}
return "#666"
}
// GameService manages NATS subscriptions and chat for Connect 4 games.
type GameService struct {
nc *nats.Conn
queries *repository.Queries
}
// NewGameService creates a new game service.
func NewGameService(nc *nats.Conn, queries *repository.Queries) *GameService {
return &GameService{
nc: nc,
queries: queries,
}
}
// SubscribeGameUpdates returns a NATS subscription and channel for game state updates.
func (s *GameService) SubscribeGameUpdates(gameID string) (*nats.Subscription, <-chan *nats.Msg, error) {
ch := make(chan *nats.Msg, 64)
sub, err := s.nc.ChanSubscribe(connect4.GameSubject(gameID), ch)
if err != nil {
return nil, nil, fmt.Errorf("subscribing to game updates: %w", err)
}
return sub, ch, nil
}
// ChatConfig returns the chat configuration for a game.
func (s *GameService) ChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "c4",
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
Color: c4ChatColor,
}
}
// ChatRoom returns a persistent chat room for a game.
func (s *GameService) ChatRoom(gameID string) *chat.Room {
return chat.NewPersistentRoom(s.nc, connect4.ChatSubject(gameID), s.queries, gameID)
}
// PublishGameUpdate sends a notification that the game state has changed.
func (s *GameService) PublishGameUpdate(gameID string) error {
return s.nc.Publish(connect4.GameSubject(gameID), nil)
}

View File

@@ -1,6 +1,10 @@
package components package components
import "github.com/starfederation/datastar-go/datastar" import (
"time"
"github.com/starfederation/datastar-go/datastar"
)
templ BackToLobby() { templ BackToLobby() {
<a class="link text-sm opacity-70" href="/">&larr; Back</a> <a class="link text-sm opacity-70" href="/">&larr; Back</a>
@@ -44,6 +48,15 @@ templ NicknamePrompt(returnPath string) {
</main> </main>
} }
// LiveClock shows the current server time, updated every second via SSE.
// If the clock stops updating, users know the connection is stale.
templ LiveClock() {
<div class="fixed top-2 right-2 flex items-center gap-1.5 text-xs opacity-60 font-mono">
<div style="width: 6px; height: 6px; border-radius: 50%; background-color: #22c55e;"></div>
{ time.Now().Format("15:04:05") }
</div>
}
templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) { templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) {
<main class="max-w-sm mx-auto mt-8 text-center"> <main class="max-w-sm mx-auto mt-8 text-center">
<h1 class="text-3xl font-bold">Join Game</h1> <h1 class="text-3xl font-bold">Join Game</h1>

View File

@@ -1,6 +1,7 @@
package layouts package layouts
import ( import (
"github.com/ryanhamamura/games/assets"
"github.com/ryanhamamura/games/config" "github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/version" "github.com/ryanhamamura/games/version"
) )
@@ -11,8 +12,8 @@ templ Base(title string) {
<head> <head>
<title>{ title }</title> <title>{ title }</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
<script defer type="module" src="/assets/js/datastar.js"></script> <script defer type="module" src={ assets.StaticPath("js/datastar.js") }></script>
<link href="/assets/css/output.css" rel="stylesheet" type="text/css"/> <link href={ assets.StaticPath("css/output.css") } rel="stylesheet" type="text/css"/>
</head> </head>
<body class="flex flex-col h-screen"> <body class="flex flex-col h-screen">
if config.Global.Environment == config.Dev { if config.Global.Environment == config.Dev {

View File

@@ -1,39 +1,24 @@
package snakegame package snakegame
import ( import (
"fmt" "errors"
"net/http" "net/http"
"strconv" "strconv"
"time"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/chat" "github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components" chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/features/snakegame/pages" "github.com/ryanhamamura/games/features/snakegame/pages"
"github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/sessions" "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake" "github.com/ryanhamamura/games/snake"
) )
func snakeChatColor(slot int) string { func HandleSnakePage(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
if slot >= 0 && slot < len(snake.SnakeColors) {
return snake.SnakeColors[slot]
}
return "#666"
}
func snakeChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "snake",
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
Color: snakeChatColor,
StopKeyPropagation: true,
}
}
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -75,13 +60,14 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
} }
sg := si.GetGame() sg := si.GetGame()
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil { chatCfg := svc.ChatConfig(gameID)
if err := pages.GamePage(sg, mySlot, nil, chatCfg, gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
} }
} }
func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc { func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -93,17 +79,25 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
playerID := sessions.GetPlayerID(sm, r) playerID := sessions.GetPlayerID(sm, r)
mySlot := si.GetPlayerSlot(playerID) mySlot := si.GetPlayerSlot(playerID)
// Subscribe to game updates BEFORE creating SSE (following portigo pattern)
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
defer gameSub.Unsubscribe() //nolint:errcheck
sse := datastar.NewSSE(w, r, datastar.WithCompression( sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)), datastar.WithBrotli(datastar.WithBrotliLevel(5)),
)) ))
chatCfg := snakeChatConfig(gameID) chatCfg := svc.ChatConfig(gameID)
// Chat room (multiplayer only) // Chat room (multiplayer only)
var room *chat.Room var room *chat.Room
sg := si.GetGame() sg := si.GetGame()
if sg.Mode == snake.ModeMultiplayer { if sg.Mode == snake.ModeMultiplayer {
room = chat.NewRoom(nc, snake.ChatSubject(gameID)) room = svc.ChatRoom(gameID)
} }
chatMessages := func() []chat.Message { chatMessages := func() []chat.Message {
@@ -116,7 +110,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
patchAll := func() error { patchAll := func() error {
si, ok = snakeStore.Get(gameID) si, ok = snakeStore.Get(gameID)
if !ok { if !ok {
return fmt.Errorf("game not found") return errors.New("game not found")
} }
mySlot = si.GetPlayerSlot(playerID) mySlot = si.GetPlayerSlot(playerID)
sg = si.GetGame() sg = si.GetGame()
@@ -128,24 +122,16 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
return return
} }
// Subscribe to game updates via NATS heartbeat := time.NewTicker(1 * time.Second)
gameCh := make(chan *nats.Msg, 64) defer heartbeat.Stop()
gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
if err != nil {
return
}
defer gameSub.Unsubscribe() //nolint:errcheck
// Chat subscription (multiplayer only) // Chat subscription (multiplayer only)
var chatCh chan *nats.Msg var chatCh <-chan chat.Message
var chatSub *nats.Subscription var cleanupChat func()
if room != nil { if room != nil {
chatCh, chatSub, err = room.Subscribe() chatCh, cleanupChat = room.Subscribe()
if err != nil { defer cleanupChat()
return
}
defer chatSub.Unsubscribe() //nolint:errcheck
} }
ctx := r.Context() ctx := r.Context()
@@ -154,6 +140,12 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
case <-ctx.Done(): case <-ctx.Done():
return return
case <-heartbeat.C:
// Heartbeat refreshes game state to keep connection alive
if err := patchAll(); err != nil {
return
}
case <-gameCh: case <-gameCh:
// Drain backed-up game updates // Drain backed-up game updates
for { for {
@@ -168,11 +160,10 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
return return
} }
case msg := <-chatCh: case chatMsg, ok := <-chatCh:
if msg == nil { if !ok {
continue continue
} }
chatMsg, _ := room.Receive(msg.Data)
err := sse.PatchElementTempl( err := sse.PatchElementTempl(
chatcomponents.ChatMessage(chatMsg, chatCfg), chatcomponents.ChatMessage(chatMsg, chatCfg),
datastar.WithSelectorID("snake-chat-history"), datastar.WithSelectorID("snake-chat-history"),
@@ -218,7 +209,7 @@ type chatSignals struct {
ChatMsg string `json:"chatMsg"` ChatMsg string `json:"chatMsg"`
} }
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc { func HandleSendChat(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
si, ok := snakeStore.Get(gameID) si, ok := snakeStore.Get(gameID)
@@ -251,7 +242,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
Message: signals.ChatMsg, Message: signals.ChatMsg,
} }
room := chat.NewRoom(nc, snake.ChatSubject(gameID)) room := svc.ChatRoom(gameID)
room.Send(msg) room.Send(msg)
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)

View File

@@ -44,6 +44,7 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) { templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
<div id="game-content"> <div id="game-content">
@components.LiveClock()
@components.BackToLobby() @components.BackToLobby()
<h1 class="text-3xl font-bold">~~~~</h1> <h1 class="text-3xl font-bold">~~~~</h1>
@snakecomponents.PlayerList(sg, mySlot) @snakecomponents.PlayerList(sg, mySlot)

View File

@@ -4,17 +4,17 @@ package snakegame
import ( import (
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go"
"github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/snake" "github.com/ryanhamamura/games/snake"
) )
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) { func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, svc *services.GameService, sessions *scs.SessionManager) {
router.Route("/snake/{id}", func(r chi.Router) { router.Route("/snake/{id}", func(r chi.Router) {
r.Get("/", HandleSnakePage(snakeStore, sessions)) r.Get("/", HandleSnakePage(snakeStore, svc, sessions))
r.Get("/events", HandleSnakeEvents(snakeStore, nc, sessions)) r.Get("/events", HandleSnakeEvents(snakeStore, svc, sessions))
r.Post("/dir", HandleSetDirection(snakeStore, sessions)) r.Post("/dir", HandleSetDirection(snakeStore, sessions))
r.Post("/chat", HandleSendChat(snakeStore, nc, sessions)) r.Post("/chat", HandleSendChat(snakeStore, svc, sessions))
r.Post("/join", HandleSetNickname(snakeStore, sessions)) r.Post("/join", HandleSetNickname(snakeStore, sessions))
r.Post("/rematch", HandleRematch(snakeStore, sessions)) r.Post("/rematch", HandleRematch(snakeStore, sessions))
}) })

View File

@@ -0,0 +1,62 @@
// Package services provides the game service layer for Snake,
// handling NATS subscriptions and chat room management.
package services
import (
"fmt"
"github.com/nats-io/nats.go"
"github.com/ryanhamamura/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/snake"
)
func snakeChatColor(slot int) string {
if slot >= 0 && slot < len(snake.SnakeColors) {
return snake.SnakeColors[slot]
}
return "#666"
}
// GameService manages NATS subscriptions and chat for Snake games.
type GameService struct {
nc *nats.Conn
}
// NewGameService creates a new game service.
func NewGameService(nc *nats.Conn) *GameService {
return &GameService{
nc: nc,
}
}
// SubscribeGameUpdates returns a NATS subscription and channel for game state updates.
func (s *GameService) SubscribeGameUpdates(gameID string) (*nats.Subscription, <-chan *nats.Msg, error) {
ch := make(chan *nats.Msg, 64)
sub, err := s.nc.ChanSubscribe(snake.GameSubject(gameID), ch)
if err != nil {
return nil, nil, fmt.Errorf("subscribing to game updates: %w", err)
}
return sub, ch, nil
}
// ChatConfig returns the chat configuration for a game.
func (s *GameService) ChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "snake",
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
Color: snakeChatColor,
StopKeyPropagation: true,
}
}
// ChatRoom returns a chat room for a game (ephemeral, not persisted).
func (s *GameService) ChatRoom(gameID string) *chat.Room {
return chat.NewRoom(s.nc, snake.ChatSubject(gameID))
}
// PublishGameUpdate sends a notification that the game state has changed.
func (s *GameService) PublishGameUpdate(gameID string) error {
return s.nc.Publish(snake.GameSubject(gameID), nil)
}

4
go.mod
View File

@@ -68,6 +68,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect github.com/aws/smithy-go v1.24.0 // indirect
github.com/benbjohnson/hashfs v0.2.2 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect github.com/bep/golibsass v1.2.0 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
@@ -170,6 +171,9 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/riza-io/grpc-go v0.2.0 // indirect github.com/riza-io/grpc-go v0.2.0 // indirect
github.com/sajari/fuzzy v1.0.0 // indirect github.com/sajari/fuzzy v1.0.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/samber/slog-common v0.20.0 // indirect
github.com/samber/slog-zerolog/v2 v2.9.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect

8
go.sum
View File

@@ -136,6 +136,8 @@ github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/benbjohnson/hashfs v0.2.2 h1:vFZtksphM5LcnMRFctj49jCUkCc7wp3NP6INyfjkse4=
github.com/benbjohnson/hashfs v0.2.2/go.mod h1:7OMXaMVo1YkfiIPxKrl7OXkUTUgWjmsAKyR+E6xDIRM=
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU= github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
@@ -565,6 +567,12 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY= github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo= github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/slog-common v0.20.0 h1:WaLnm/aCvBJSk5nR5aXZTFBaV0B47A+AEaEOiZDeUnc=
github.com/samber/slog-common v0.20.0/go.mod h1:+Ozat1jgnnE59UAlmNX1IF3IByHsODnnwf9jUcBZ+m8=
github.com/samber/slog-zerolog/v2 v2.9.1 h1:RMOq8XqzfuGx1X0TEIlS9OXbbFmqLY2/wJppghz66YY=
github.com/samber/slog-zerolog/v2 v2.9.1/go.mod h1:DQYYve14WgCRN/XnKeHl4266jXK0DgYkYXkfZ4Fp98k=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=

View File

@@ -5,10 +5,11 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/ryanhamamura/games/config" "github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/ryanhamamura/games/config"
) )
const ( const (
@@ -64,25 +65,15 @@ func colorLatency(d time.Duration, useColor bool) string {
} }
} }
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
func RequestLogger(logger *zerolog.Logger, env config.Environment) func(http.Handler) http.Handler { func RequestLogger(logger *zerolog.Logger, env config.Environment) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
rw := &responseWriter{ResponseWriter: w} ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(rw, r) next.ServeHTTP(ww, r)
status := rw.status status := ww.Status()
if status == 0 { if status == 0 {
status = http.StatusOK status = http.StatusOK
} }

28
main.go
View File

@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"embed"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
@@ -11,6 +10,12 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log"
slogzerolog "github.com/samber/slog-zerolog/v2"
"golang.org/x/sync/errgroup"
"github.com/ryanhamamura/games/config" "github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/connect4" "github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db" "github.com/ryanhamamura/games/db"
@@ -21,22 +26,19 @@ import (
"github.com/ryanhamamura/games/sessions" "github.com/ryanhamamura/games/sessions"
"github.com/ryanhamamura/games/snake" "github.com/ryanhamamura/games/snake"
"github.com/ryanhamamura/games/version" "github.com/ryanhamamura/games/version"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log"
"golang.org/x/sync/errgroup"
) )
//go:embed assets
var assets embed.FS
func main() { func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
cfg := config.Global cfg := config.Global
logging.SetupLogger(cfg.Environment, cfg.LogLevel) zerologLogger := logging.SetupLogger(cfg.Environment, cfg.LogLevel)
slog.SetDefault(slog.New(slogzerolog.Option{
Level: slogzerolog.ZeroLogLeveler{Logger: zerologLogger},
Logger: zerologLogger,
NoTimestamp: true,
}.NewZerologHandler()))
if err := run(ctx); err != nil && err != http.ErrServerClosed { if err := run(ctx); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("server error") log.Fatal().Err(err).Msg("server error")
@@ -91,7 +93,7 @@ func run(ctx context.Context) error {
sessionManager.LoadAndSave, sessionManager.LoadAndSave,
) )
router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, assets) router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore)
// HTTP server // HTTP server
srv := &http.Server{ srv := &http.Server{
@@ -101,6 +103,10 @@ func run(ctx context.Context) error {
BaseContext: func(l net.Listener) context.Context { BaseContext: func(l net.Listener) context.Context {
return egctx return egctx
}, },
ErrorLog: slog.NewLogLogger(
slog.Default().Handler(),
slog.LevelError,
),
} }
eg.Go(func() error { eg.Go(func() error {

View File

@@ -2,24 +2,25 @@
package router package router
import ( import (
"embed"
"io/fs"
"net/http" "net/http"
"sync" "sync"
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/auth"
"github.com/ryanhamamura/games/features/c4game"
"github.com/ryanhamamura/games/features/lobby"
"github.com/ryanhamamura/games/features/snakegame"
"github.com/ryanhamamura/games/snake"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/assets"
"github.com/ryanhamamura/games/config"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/auth"
"github.com/ryanhamamura/games/features/c4game"
c4services "github.com/ryanhamamura/games/features/c4game/services"
"github.com/ryanhamamura/games/features/lobby"
"github.com/ryanhamamura/games/features/snakegame"
snakeservices "github.com/ryanhamamura/games/features/snakegame/services"
"github.com/ryanhamamura/games/snake"
) )
func SetupRoutes( func SetupRoutes(
@@ -29,21 +30,23 @@ func SetupRoutes(
nc *nats.Conn, nc *nats.Conn,
store *connect4.Store, store *connect4.Store,
snakeStore *snake.SnakeStore, snakeStore *snake.SnakeStore,
assets embed.FS,
) { ) {
// Static assets // Static assets
subFS, _ := fs.Sub(assets, "assets") router.Handle("/assets/*", assets.Handler())
router.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServerFS(subFS)))
// Hot-reload for development // Hot-reload for development
if config.Global.Environment == config.Dev { if config.Global.Environment == config.Dev {
setupReload(router) setupReload(router)
} }
// Services
c4Svc := c4services.NewGameService(nc, queries)
snakeSvc := snakeservices.NewGameService(nc)
auth.SetupRoutes(router, queries, sessions) auth.SetupRoutes(router, queries, sessions)
lobby.SetupRoutes(router, queries, sessions, store, snakeStore) lobby.SetupRoutes(router, queries, sessions, store, snakeStore)
c4game.SetupRoutes(router, store, nc, sessions, queries) c4game.SetupRoutes(router, store, c4Svc, sessions)
snakegame.SetupRoutes(router, snakeStore, nc, sessions) snakegame.SetupRoutes(router, snakeStore, snakeSvc, sessions)
} }
func setupReload(router chi.Router) { func setupReload(router chi.Router) {

View File

@@ -33,7 +33,7 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) {
sessionManager.Cookie.Name = "games_session" sessionManager.Cookie.Name = "games_session"
sessionManager.Cookie.Path = "/" sessionManager.Cookie.Path = "/"
sessionManager.Cookie.HttpOnly = true sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.Secure = true sessionManager.Cookie.Secure = false
sessionManager.Cookie.SameSite = http.SameSiteLaxMode sessionManager.Cookie.SameSite = http.SameSiteLaxMode
slog.Info("session manager configured") slog.Info("session manager configured")