DEV Community

Cover image for Adventure: Building with NATS Jetstream KV Store -Part 10
Richard
Richard

Posted on

Adventure: Building with NATS Jetstream KV Store -Part 10

Hello again! We're back!

Welcome back again to my adventure series on the NATS JetStream Key/Value Store!

We're getting close! We just need to make the Dashboard and the Game routes! Let's go!


Where did we leave off now?

We had just finished setting up logging into our application and saving sessions. Get ready, the meat of our application is starting now!

Getting into the thick of it

The Dashboard route has a lot more going on. We're going to be watching for games created by us and any other person playing. We need to keep track of state for these games, such as if the game is available to join or full. We also need to watch for other actions such as game deletions!

This should be fun! Let's start with the code itself and break it down! Let's checkout setupDashboardRoute().

setupDashboardRoute

package routes

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "strings"
    "time"

    "github.com/delaneyj/toolbelt"
    "github.com/go-chi/chi/v5"
    "github.com/goombaio/namegenerator"
    "github.com/gorilla/sessions"
    "github.com/nats-io/nats.go/jetstream"
    "github.com/rphumulock/ds_nats_ttt/web/components"
    "github.com/rphumulock/ds_nats_ttt/web/pages"

    datastar "github.com/starfederation/datastar/sdk/go"
)

func setupDashboardRoute(router chi.Router, store sessions.Store, js jetstream.JetStream) error {
    ctx := context.Background()

    gameLobbiesKV, err := js.KeyValue(ctx, "gameLobbies")
    if err != nil {
        return fmt.Errorf("failed to get game lobbies key value: %w", err)
    }

    gameBoardsKV, err := js.KeyValue(ctx, "gameBoards")
    if err != nil {
        return fmt.Errorf("failed to get game lobbies key value: %w", err)
    }

    usersKV, err := js.KeyValue(ctx, "users")
    if err != nil {
        return fmt.Errorf("failed to get users key value: %w", err)
    }

    handleGetDashboard := func(w http.ResponseWriter, r *http.Request) {
        sessionId, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        if sessionId == "" {
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        user, _, err := GetObject[components.User](ctx, usersKV, sessionId)
        if err != nil {
            deleteSessionId(store, w, r)
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        pages.Dashboard(user.Name).Render(r.Context(), w)
    }

    router.Get("/dashboard", handleGetDashboard)

    // API

    generateGameDetails := func() (string, string) {
        id := toolbelt.NextEncodedID()
        seed := time.Now().UTC().UnixNano()
        nameGenerator := namegenerator.NewNameGenerator(seed)
        name := strings.ToUpper(nameGenerator.Generate())
        return id, name
    }

    createGameLobby := func(id, name, sessionId string) components.GameLobby {
        return components.GameLobby{
            Id:           id,
            Name:         name,
            HostId:       sessionId,
            ChallengerId: "",
        }
    }

    createGameState := func(id string) components.GameState {
        return components.GameState{
            Id:      id,
            Board:   [9]string{},
            XIsNext: true,
            Winner:  "",
        }
    }

    handleCreate := func(w http.ResponseWriter, r *http.Request) {
        sessionId, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        id, name := generateGameDetails()
        gameLobby := createGameLobby(id, name, sessionId)
        if err := PutData(r.Context(), gameLobbiesKV, id, gameLobby); err != nil {
            http.Error(w, fmt.Sprintf("failed to store game lobby: %v", err), http.StatusInternalServerError)
            return
        }
        gameState := createGameState(id)
        if err := PutData(r.Context(), gameBoardsKV, id, gameState); err != nil {
            http.Error(w, fmt.Sprintf("failed to store game state: %v", err), http.StatusInternalServerError)
            return
        }
    }

    handleLogout := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        sessionId, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        if sessionId == "" {
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        keys, err := gameLobbiesKV.Keys(ctx)
        if err != nil {
            log.Printf("%v", err)
        }

        for _, key := range keys {
            entry, err := gameLobbiesKV.Get(ctx, key)
            if err != nil {
                log.Printf("Failed to get value for key %s: %v", key, err)
                continue
            }

            var gameLobby components.GameLobby
            if err := json.Unmarshal(entry.Value(), &gameLobby); err != nil {
                log.Printf("Error unmarshalling update value: %v", err)
                return
            }

            if gameLobby.HostId == sessionId {
                gameLobbiesKV.Delete(ctx, key)
                gameBoardsKV.Delete(ctx, key)
            }
        }

        if err := usersKV.Delete(ctx, sessionId); err != nil {
            http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", sessionId, err), http.StatusInternalServerError)
            return
        }
        deleteSessionId(store, w, r)
        sse := datastar.NewSSE(w, r)
        sse.Redirect("/")
    }

    handleHistoricalUpdates := func(dashboardItems []components.GameLobby, sessionId string, sse *datastar.ServerSentEventGenerator) {
        if len(dashboardItems) == 0 {
            return
        }

        c := components.DashboardList(dashboardItems, sessionId)
        if err := sse.MergeFragmentTempl(c); err != nil {
            sse.ConsoleError(err)
        }
        dashboardItems = nil
    }

    handleKeyValueDelete := func(historicalMode bool, update jetstream.KeyValueEntry, sse *datastar.ServerSentEventGenerator) {
        if historicalMode {
            log.Printf("Ignoring historical delete for key: %s", update.Key())
            return
        }

        if err := sse.RemoveFragments("#game-"+update.Key(),
            datastar.WithRemoveSettleDuration(1*time.Millisecond),
            datastar.WithRemoveUseViewTransitions(false)); err != nil {
            sse.ConsoleError(err)
        }

    }

    handleKeyValuePut := func(
        ctx context.Context,
        historicalMode bool,
        dashboardItems *[]components.GameLobby,
        entry jetstream.KeyValueEntry,
        sessionId string,
        sse *datastar.ServerSentEventGenerator,
    ) {
        var gameLobby components.GameLobby
        if err := json.Unmarshal(entry.Value(), &gameLobby); err != nil {
            log.Printf("Error unmarshalling update value for key %s: %v", entry.Key(), err)
            return
        }

        if historicalMode {
            *dashboardItems = append(*dashboardItems, gameLobby)
            return
        }

        history, err := gameLobbiesKV.History(ctx, entry.Key())
        if err != nil {
            log.Printf("Error getting history for key %s: %v", entry.Key(), err)
            return
        }

        c := components.DashboardListItem(&gameLobby, sessionId)
        if len(history) == 1 {
            if err := sse.MergeFragmentTempl(c,
                datastar.WithSelectorID("list-container"),
                datastar.WithMergeAppend()); err != nil {
                sse.ConsoleError(err)
            }
        } else {
            if err := sse.MergeFragmentTempl(c,
                datastar.WithSelectorID("game-"+entry.Key()),
                datastar.WithMergeMorph()); err != nil {
                sse.ConsoleError(err)
            }
        }
    }

    handleUpdates := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        sse := datastar.NewSSE(w, r)

        sessionId, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        watcher, err := gameLobbiesKV.WatchAll(ctx)
        if err != nil {
            http.Error(w, fmt.Sprintf("Failed to start watcher: %v", err), http.StatusInternalServerError)
            return
        }
        defer watcher.Stop()

        historicalMode := true
        dashboardItems := &[]components.GameLobby{}

        for {
            select {
            case <-ctx.Done():
                log.Println("Context canceled, stopping watcher updates")
                return
            case entry, ok := <-watcher.Updates():
                if !ok {
                    log.Println("Watcher updates channel closed")
                    return
                }

                if entry == nil {
                    handleHistoricalUpdates(*dashboardItems, sessionId, sse)
                    historicalMode = false
                    continue
                }

                switch entry.Operation() {
                case jetstream.KeyValuePut:
                    handleKeyValuePut(ctx, historicalMode, dashboardItems, entry, sessionId, sse)
                case jetstream.KeyValuePurge:
                    handleKeyValueDelete(historicalMode, entry, sse)
                }
            }
        }
    }

    handlePurge := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()

        keys, err := gameLobbiesKV.Keys(ctx)
        if err != nil {
            log.Printf("Error listing keys: %v", err)
            return
        }

        for _, key := range keys {
            err = gameLobbiesKV.Purge(ctx, key)
            if err != nil {
                log.Printf("Error deleting key '%s': %v", key, err)
                continue
            }

            err = gameBoardsKV.Purge(ctx, key)
            if err != nil {
                log.Printf("Error deleting key '%s': %v", key, err)
                continue
            }

            log.Printf("Deleted key: %s", key)
        }

        fmt.Fprintln(w, "All games have been purged.")
    }

    handleJoin := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        sse := datastar.NewSSE(w, r)

        id := chi.URLParam(r, "id")
        if id == "" {
            http.Error(w, "missing 'id' parameter", http.StatusBadRequest)
            return
        }

        sessionID, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, fmt.Sprintf("failed to get session: %v", err), http.StatusInternalServerError)
            return
        }
        if sessionID == "" {
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        gameLobby, entry, err := GetObject[components.GameLobby](ctx, gameLobbiesKV, id)
        if err != nil {
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        if sessionID != gameLobby.HostId {

            if gameLobby.ChallengerId != "" && gameLobby.ChallengerId != sessionID {
                sse.Redirect("/dashboard")
                sse.ExecuteScript("alert('Another player has already joined. Game is full.');")
                return
            }

            gameLobby.ChallengerId = sessionID

            if err := UpdateData(ctx, gameLobbiesKV, id, gameLobby, entry); err != nil {
                sse.ExecuteScript("alert('Someone else joined first. This lobby is now full.');")
                sse.Redirect("/dashboard")
                return
            }
        }

        sse.Redirect("/game/" + id)
    }

    handleDelete := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()

        id := chi.URLParam(r, "id")
        if id == "" {
            http.Error(w, "missing 'id' parameter", http.StatusBadRequest)
            return
        }

        if err := gameLobbiesKV.Purge(ctx, id); err != nil {
            http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", id, err), http.StatusInternalServerError)
            return
        }
        if err := gameBoardsKV.Purge(ctx, id); err != nil {
            http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", id, err), http.StatusInternalServerError)
            return
        }
    }

    router.Route("/api/dashboard", func(dashboardRouter chi.Router) {

        dashboardRouter.Post("/create", handleCreate)

        dashboardRouter.Post("/logout", handleLogout)

        dashboardRouter.Get("/updates", handleUpdates)

        dashboardRouter.Delete("/purge", handlePurge)

        dashboardRouter.Route("/{id}", func(gameIdRouter chi.Router) {

            gameIdRouter.Post("/join", handleJoin)

            gameIdRouter.Delete("/delete", handleDelete)

        })

    })

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Break it down!

Let's break this bad mamma-jamma down. When we first hit the route we render the page after checking the session and getting the user so we can pass the users name to our component template. We use GetObject() as a helper function to do this. Go ahead and add this function to routes/utils.go.


func GetObject[T any](ctx context.Context, kv jetstream.KeyValue, key string) (*T, jetstream.KeyValueEntry, error) {
    entry, err := kv.Get(ctx, key)
    if err != nil {
        return nil, nil, fmt.Errorf("failed to get key %s: %w", key, err)
    }

    var obj T
    if err := json.Unmarshal(entry.Value(), &obj); err != nil {
        return nil, nil, fmt.Errorf("failed to unmarshal value for key %s: %w", key, err)
    }

    return &obj, entry, nil
}
Enter fullscreen mode Exit fullscreen mode

We will be using this function more in the future.

    handleGetDashboard := func(w http.ResponseWriter, r *http.Request) {
        sessionId, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        if sessionId == "" {
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        user, _, err := GetObject[components.User](ctx, usersKV, sessionId)
        if err != nil {
            deleteSessionId(store, w, r)
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        pages.Dashboard(user.Name).Render(r.Context(), w)
    }

    router.Get("/dashboard", handleGetDashboard)
Enter fullscreen mode Exit fullscreen mode

Let's create that template now.

$ touch components/dashboard.templ
Enter fullscreen mode Exit fullscreen mode

Copy this into it.

package components

import (
    "fmt"
    datastar "github.com/starfederation/datastar/sdk/go"
)

templ Dashboard(name string) {
    {{
        isAdmin := name == "admin"
    }}
    <div data-on-load={ datastar.GetSSE("/api/dashboard/updates") }>
        <div class="flex flex-col sm:flex-row items-center p-4 bg-accent shadow-md w-full mb-4 rounded-md">
            <div class="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
                <button
                    class="btn btn-primary rounded-md flex items-center justify-center text-center text-primary-content px-4 py-2 sm:px-6 sm:py-3 w-full sm:w-auto"
                    data-on-click={ datastar.PostSSE("/api/dashboard/create") }
                >
                    🎮 Create Game
                </button>
                <button
                    class="btn btn-secondary rounded-md px-4 py-2 sm:px-6 sm:py-3 text-secondary-content w-full sm:w-auto"
                    data-on-click={ datastar.PostSSE("/api/dashboard/logout") }
                >
                    🚪 Logout
                </button>
                if (isAdmin) {
                    <button
                        class="btn btn-error rounded-md flex items-center justify-center text-center text-error-content px-4 py-2 sm:px-6 sm:py-3 w-full sm:w-auto"
                        data-on-click={ datastar.DeleteSSE("/api/dashboard/purge") }
                    >
                        🗑️ Delete Games
                    </button>
                }
            </div>
        </div>
        <div
            id="list-container"
            class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full overflow-y-auto"
            style="max-height: 75vh;"
        ></div>
    </div>
}

templ DashboardList(list []GameLobby, sessionId string) {
    <div
        id="list-container"
        class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full overflow-y-auto"
        style="max-height: 75vh;"
    >
        for _, listItem := range list {
            @DashboardListItem(&listItem, sessionId)
        }
    </div>
}

templ DashboardListItem(gameLobby *GameLobby, sessionId string) {
    {{
        isHost := gameLobby.HostId == sessionId
        isChallenger := gameLobby.ChallengerId == sessionId
        isFull := gameLobby.ChallengerId != ""

        gameSelector := fmt.Sprintf("game-%s", gameLobby.Id)
        showJoinButton := isHost || isChallenger || !isFull

        var status string
        if isFull && !isHost && !isChallenger {
            status = "🔒 Game is Full"
        } else if isFull && isHost {
            status = "✅ Game is Ready!"
        } else if isFull && isChallenger {
            status = "✅ Game is Ready!"
        } else {
            status = "🟢 Game is Open"
        }

        colorClass := "bg-base-300 text-base-content"
        if isHost {
            colorClass = "bg-base-100 text-primary-content"
        }

        cardClasses := fmt.Sprintf("p-6 shadow-lg flex flex-col w-full min-h-[220px] rounded-md %s", colorClass)
    }}
    <div id={ gameSelector } class={ cardClasses }>
        <p class="tracking-widest text-secondary-content text-sm font-bold">
            🎮 Game: { gameLobby.Name }
        </p>
        <p class="tracking-widest text-secondary-content text-sm font-bold">
            📊 Status: { status }
        </p>
        <div class="flex flex-col items-center justify-center w-full gap-2">
            if showJoinButton {
                <button
                    class="btn btn-primary rounded-md flex items-center justify-center text-center text-primary-content w-full m-2 h-12 px-4"
                    data-on-click={ datastar.PostSSE("/api/dashboard/%s/join", gameLobby.Id) }
                >
                    🕹️ Join
                </button>
            }
            if isHost {
                <button
                    class="btn btn-secondary rounded-md flex items-center justify-center text-center text-secondary-content w-full m-2 h-12 px-4"
                    data-on-click={ datastar.DeleteSSE("/api/dashboard/%s/delete", gameLobby.Id) }
                >
                    🗑️ Delete
                </button>
            }
        </div>
    </div>
}
Enter fullscreen mode Exit fullscreen mode

Here we have three components. One for the Dashboard page itself, one for the list of items on the Dashboard and one for the items themselves.

So you see when we land on the Dashboard route, we show the Dashboard page which will call our updates endpoint on load.

<div data-on-load={ datastar.GetSSE("/api/dashboard/updates") 
Enter fullscreen mode Exit fullscreen mode

What does this end up doing?

dashboardRouter.Get("/updates", handleUpdates)
Enter fullscreen mode Exit fullscreen mode

Let's check it out!

    handleUpdates := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        sse := datastar.NewSSE(w, r)

        sessionId, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        watcher, err := gameLobbiesKV.WatchAll(ctx)
        if err != nil {
            http.Error(w, fmt.Sprintf("Failed to start watcher: %v", err), http.StatusInternalServerError)
            return
        }
        defer watcher.Stop()

        historicalMode := true
        dashboardItems := &[]components.GameLobby{}

        for {
            select {
            case <-ctx.Done():
                log.Println("Context canceled, stopping watcher updates")
                return
            case entry, ok := <-watcher.Updates():
                if !ok {
                    log.Println("Watcher updates channel closed")
                    return
                }

                if entry == nil {
                    handleHistoricalUpdates(*dashboardItems, sessionId, sse)
                    historicalMode = false
                    continue
                }

                switch entry.Operation() {
                case jetstream.KeyValuePut:
                    handleKeyValuePut(ctx, historicalMode, dashboardItems, entry, sessionId, sse)
                case jetstream.KeyValuePurge:
                    handleKeyValueDelete(historicalMode, entry, sse)
                }
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Does anything look familiar? It should! This is entirely powered by NATS Jetstream KV Store watch command!

All we do is get the id of the current game from the url parameters. We then simply watch on that key! We've done this before!

If you recall, it will always iterate over the historical data before streaming new current events. We use the variable historicalMode to keep track of this.

 historicalMode := true
        dashboardItems := &[]components.GameLobby{}

        for {
            select {
            case <-ctx.Done():
                log.Println("Context canceled, stopping watcher updates")
                return
            case entry, ok := <-watcher.Updates():
                if !ok {
                    log.Println("Watcher updates channel closed")
                    return
                }

                if entry == nil {
                    handleHistoricalUpdates(*dashboardItems, sessionId, sse)
                    historicalMode = false
                    continue
                }

                switch entry.Operation() {
                case jetstream.KeyValuePut:
                    handleKeyValuePut(ctx, historicalMode, dashboardItems, entry, sessionId, sse)
                case jetstream.KeyValuePurge:
                    handleKeyValueDelete(historicalMode, entry, sse)
                }
            }
        }
Enter fullscreen mode Exit fullscreen mode

As events come in from the stream, historical or current, we check the operation type. Let's check out when it is a put operation.

handleKeyValuePut

First, let's talk about historical events.

handleKeyValuePut := func(
        ctx context.Context,
        historicalMode bool,
        dashboardItems *[]components.GameLobby,
        entry jetstream.KeyValueEntry,
        sessionId string,
        sse *datastar.ServerSentEventGenerator,
    ) {
        var gameLobby components.GameLobby
        if err := json.Unmarshal(entry.Value(), &gameLobby); err != nil {
            log.Printf("Error unmarshalling update value for key %s: %v", entry.Key(), err)
            return
        }

        if historicalMode {
            *dashboardItems = append(*dashboardItems, gameLobby)
            return
        }
Enter fullscreen mode Exit fullscreen mode

If the event was historical and being replayed and was a put operation, we simply get the event and unmarshal it into our struct and then add it to a slice. Then we can see, back in our loop over our stream, once all historical events are played we call handleHisotricalUpdates()

handleHistoricalUpdates := func(dashboardItems []components.GameLobby, sessionId string, sse *datastar.ServerSentEventGenerator) {
        if len(dashboardItems) == 0 {
            return
        }

        c := components.DashboardList(dashboardItems, sessionId)
        if err := sse.MergeFragmentTempl(c); err != nil {
            sse.ConsoleError(err)
        }
        dashboardItems = nil
    }
Enter fullscreen mode Exit fullscreen mode

This function simply takes that array and creates a list of the items. Then using Datastar, we merge that lits into the UI. Easy peasy.


What about real time updates?

Glad you asked Let's check out the code again.

   case entry, ok := <-watcher.Updates():
                if !ok {
                    log.Println("Watcher updates channel closed")
                    return
                }

                if entry == nil {
                    handleHistoricalUpdates(*dashboardItems, sessionId, sse)
                    historicalMode = false
                    continue
                }

                switch entry.Operation() {
                case jetstream.KeyValuePut:
                    handleKeyValuePut(ctx, historicalMode, dashboardItems, entry, sessionId, sse)
                case jetstream.KeyValuePurge:
                    handleKeyValueDelete(historicalMode, entry, sse)
                }
            }
Enter fullscreen mode Exit fullscreen mode

Once historical events are finished, we set the historicalMode variable to false. Now when we get new put events we do something else in handleKeyValuePut()

 if historicalMode {
            *dashboardItems = append(*dashboardItems, gameLobby)
            return
        }

        history, err := gameLobbiesKV.History(ctx, entry.Key())
        if err != nil {
            log.Printf("Error getting history for key %s: %v", entry.Key(), err)
            return
        }

        c := components.DashboardListItem(&gameLobby, sessionId)
        if len(history) == 1 {
            if err := sse.MergeFragmentTempl(c,
                datastar.WithSelectorID("list-container"),
                datastar.WithMergeAppend()); err != nil {
                sse.ConsoleError(err)
            }
        } else {
            if err := sse.MergeFragmentTempl(c,
                datastar.WithSelectorID("game-"+entry.Key()),
                datastar.WithMergeMorph()); err != nil {
                sse.ConsoleError(err)
            }
        }
Enter fullscreen mode Exit fullscreen mode

If we are not receiving historical updates anymore, we continue on and create a DashboardListItem component. Check it out-we use the History command here!

We use the history command to check the history of the key. If it's freshly created, we want to append it to the DOM, so using Datastar we do just that and append it to the list. If it's not just being created then it already exists in the DOM and we're performing a different operation on the list item itself, so we morph it. This will take place to alter the list items information for everyone to see!

This is all that this is doing. We iterate over the historical events from our stream-when they are finished the event entry will be nil. This is how we know that the historical replay of events is over with. We then just set the historicalMode variable to reflect this.

After that, any event that occurs is a current/in the moment event. So we can see that for historical events we call handleHistoricalUpdates() and for current events we check the operation type and call either handleKeyValuePut() or handleKeyValueDelete().

Delete is rather simple.

 if historicalMode {
            log.Printf("Ignoring historical delete for key: %s", update.Key())
            return
        }

        if err := sse.RemoveFragments("#game-"+update.Key(),
            datastar.WithRemoveSettleDuration(1*time.Millisecond),
            datastar.WithRemoveUseViewTransitions(false)); err != nil {
            sse.ConsoleError(err)
        }
Enter fullscreen mode Exit fullscreen mode

If it's a historical delete, we simply ignore it. The item was deleted and we don't need to react to this at all. If it's live, then we remove the specific DashboardListItem based on it's key!

This is it for live updates, it's really that easy. This code isn't even that good and it works well. I really should refactor it to make it better!

NATS Jetstream makes it so easy to do this sort of thing on the fly.


What else?

Let's check out some of our other endpoints.

  router.Route("/api/dashboard", func(dashboardRouter chi.Router) {

        dashboardRouter.Post("/create", handleCreate)

        dashboardRouter.Post("/logout", handleLogout)

        dashboardRouter.Get("/updates", handleUpdates)

        dashboardRouter.Delete("/purge", handlePurge)

        dashboardRouter.Route("/{id}", func(gameIdRouter chi.Router) {

            gameIdRouter.Post("/join", handleJoin)

            gameIdRouter.Delete("/delete", handleDelete)

        })

    })
Enter fullscreen mode Exit fullscreen mode

We've covered update. The others are self explanatory but let's go over them anyhow.

Create

Create is fairly straight-forward. Nothing you haven't seen before.

    generateGameDetails := func() (string, string) {
        id := toolbelt.NextEncodedID()
        seed := time.Now().UTC().UnixNano()
        nameGenerator := namegenerator.NewNameGenerator(seed)
        name := strings.ToUpper(nameGenerator.Generate())
        return id, name
    }

    createGameLobby := func(id, name, sessionId string) components.GameLobby {
        return components.GameLobby{
            Id:           id,
            Name:         name,
            HostId:       sessionId,
            ChallengerId: "",
        }
    }

    createGameState := func(id string) components.GameState {
        return components.GameState{
            Id:      id,
            Board:   [9]string{},
            XIsNext: true,
            Winner:  "",
        }
    }

    handleCreate := func(w http.ResponseWriter, r *http.Request) {
        sessionId, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        id, name := generateGameDetails()
        gameLobby := createGameLobby(id, name, sessionId)
        if err := PutData(r.Context(), gameLobbiesKV, id, gameLobby); err != nil {
            http.Error(w, fmt.Sprintf("failed to store game lobby: %v", err), http.StatusInternalServerError)
            return
        }
        gameState := createGameState(id)
        if err := PutData(r.Context(), gameBoardsKV, id, gameState); err != nil {
            http.Error(w, fmt.Sprintf("failed to store game state: %v", err), http.StatusInternalServerError)
            return
        }
    }
Enter fullscreen mode Exit fullscreen mode

We generate a name an random id for our game with generateGameDetails(). Then we just use that to create a GameLobby which will be shown on our Dashboard as a DashboardListItem. We also create a new game, instantiating these both with some default values. You can see that a GameLobby is created with the HostId supplied and an empty ChallengerId. You can see the GameState itself defaults to X being first and has no current winner, just an empty board represented by a slice of 9 which represents our game cells.


Logout

Logout is also simple.

 handleLogout := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        sessionId, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        if sessionId == "" {
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        keys, err := gameLobbiesKV.Keys(ctx)
        if err != nil {
            log.Printf("%v", err)
        }

        for _, key := range keys {
            entry, err := gameLobbiesKV.Get(ctx, key)
            if err != nil {
                log.Printf("Failed to get value for key %s: %v", key, err)
                continue
            }

            var gameLobby components.GameLobby
            if err := json.Unmarshal(entry.Value(), &gameLobby); err != nil {
                log.Printf("Error unmarshalling update value: %v", err)
                return
            }

            if gameLobby.HostId == sessionId {
                gameLobbiesKV.Delete(ctx, key)
                gameBoardsKV.Delete(ctx, key)
            }
        }

        if err := usersKV.Delete(ctx, sessionId); err != nil {
            http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", sessionId, err), http.StatusInternalServerError)
            return
        }
        deleteSessionId(store, w, r)
        sse := datastar.NewSSE(w, r)
        sse.Redirect("/")
    }
Enter fullscreen mode Exit fullscreen mode

It's essentially just clean up. We get our session for our current user and then iterate over all of that users Games and delete them all. Then we delete the User. This isn't normal logout behavior but we're doing it just for the sake of this project. In the future maybe I'll make a system that keeps track of users better, but currently this is what we're doing.

Purge

We'll cover the purge endpoint because it's similar. With this, we don't delete the user session, but we do delete all the games that exist. I added this for debugging but also just for a fun learning experience. To use this endpoint, login as admin.

handlePurge := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()

        keys, err := gameLobbiesKV.Keys(ctx)
        if err != nil {
            log.Printf("Error listing keys: %v", err)
            return
        }

        for _, key := range keys {
            err = gameLobbiesKV.Purge(ctx, key)
            if err != nil {
                log.Printf("Error deleting key '%s': %v", key, err)
                continue
            }

            err = gameBoardsKV.Purge(ctx, key)
            if err != nil {
                log.Printf("Error deleting key '%s': %v", key, err)
                continue
            }

            log.Printf("Deleted key: %s", key)
        }

        fmt.Fprintln(w, "All games have been purged.")
    }
Enter fullscreen mode Exit fullscreen mode

Quite simple if you ask me.


Joining and Removing Games

The last two! How do we Join and Remove games? For one of these, we will use the update command! See? We're using all of our commands!

Joining Games

We will be using our update command, let's add a helper for it. In our routes/utils.go file add this.

func UpdateData(ctx context.Context, kv jetstream.KeyValue, id string, data interface{}, entry jetstream.KeyValueEntry) error {
    bytes, err := json.Marshal(data)
    if err != nil {
        return fmt.Errorf("failed to marshal JSON: %w", err)
    }

    _, err = kv.Update(ctx, id, bytes, entry.Revision())
    if err != nil {
        return fmt.Errorf("failed to update key-value: %w", err)
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

This will use the current entrys revision number, just like we used earlier in our series, to update our value!

handleJoin := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        sse := datastar.NewSSE(w, r)

        id := chi.URLParam(r, "id")
        if id == "" {
            http.Error(w, "missing 'id' parameter", http.StatusBadRequest)
            return
        }

        sessionID, err := getSessionId(store, r)
        if err != nil {
            http.Error(w, fmt.Sprintf("failed to get session: %v", err), http.StatusInternalServerError)
            return
        }
        if sessionID == "" {
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        gameLobby, entry, err := GetObject[components.GameLobby](ctx, gameLobbiesKV, id)
        if err != nil {
            http.Redirect(w, r, "/", http.StatusSeeOther)
            return
        }

        if sessionID != gameLobby.HostId {

            if gameLobby.ChallengerId != "" && gameLobby.ChallengerId != sessionID {
                sse.Redirect("/dashboard")
                sse.ExecuteScript("alert('Another player has already joined. Game is full.');")
                return
            }

            gameLobby.ChallengerId = sessionID

            if err := UpdateData(ctx, gameLobbiesKV, id, gameLobby, entry); err != nil {
                sse.ExecuteScript("alert('Someone else joined first. This lobby is now full.');")
                sse.Redirect("/dashboard")
                return
            }
        }

        sse.Redirect("/game/" + id)
    }
Enter fullscreen mode Exit fullscreen mode

Similar stuff, we get the session and the GameLobby and then we check if the person joining is the host or not. If it's the host, we just redirect them to their game. If it's a challenger, we check to make sure the game doesn't already have a challenger. If it does we yell at the person. If it doesn't we add this person as the new challenger.

We haven't saved this to the KV Store yet though, so it's not solid! We attempt to make an update to the store, and if the revision number we had gotten earlier with the GameLobby isn't correct, we yell at this person! (A lot of yelling going on, sheesh!)

We do this because two people might click this at the same time! In the words of Connor and Duncan, THERE CAN BE ONLY ONE!

If all goes well, the challenger is now added and we redirect to the game!

Deleting a Game

This is far more simple.

  handleDelete := func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()

        id := chi.URLParam(r, "id")
        if id == "" {
            http.Error(w, "missing 'id' parameter", http.StatusBadRequest)
            return
        }

        if err := gameLobbiesKV.Purge(ctx, id); err != nil {
            http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", id, err), http.StatusInternalServerError)
            return
        }
        if err := gameBoardsKV.Purge(ctx, id); err != nil {
            http.Error(w, fmt.Sprintf("failed to delete key '%s': %v", id, err), http.StatusInternalServerError)
            return
        }
    }
Enter fullscreen mode Exit fullscreen mode

We just get the id of the game and purge it from the lobbies and games buckets.


Wowo! Ok the dashboard should be in working order. Test it and see!

Time to play, let's move on to Part 11 and finish the Game page!

Top comments (0)