Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ All examples follow the [progressive complexity](https://github.com/livetemplate
| `live-preview/` | 1 | Change() live updates | None |
| `login/` | 1 | Authentication + sessions | None |
| `shared-notepad/` | 1 | BasicAuth + SharedState | None |
| `ephemeral-counter/` | 1 | Ephemeral state with in-memory DB | None |
| `ephemeral-todos/` | 1 | Ephemeral state with SQLite DB | None |

## Examples

Expand Down
31 changes: 31 additions & 0 deletions ephemeral-counter/counter.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>Ephemeral Counter</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
</head>
<body>
<main class="container">
<article>
<header>
<h1>Ephemeral Counter</h1>
<p><small>Database is the source of truth. State is rebuilt from DB on every page load.</small></p>
</header>
<p>Count: {{.Count}}</p>
<div class="grid">
<button name="increment">+1</button>
<button name="decrement" class="secondary">-1</button>
<button name="reset" class="contrast">Reset</button>
</div>
</article>
</main>

{{if .lvt.DevMode}}
<script src="/livetemplate-client.js"></script>
{{else}}
<script src="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/dist/livetemplate-client.browser.js"></script>
{{end}}
</body>
</html>
107 changes: 107 additions & 0 deletions ephemeral-counter/counter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package main

import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"

"github.com/chromedp/chromedp"
e2etest "github.com/livetemplate/lvt/testing"
)

func TestMain(m *testing.M) {
e2etest.CleanupChromeContainers()
code := m.Run()
e2etest.CleanupChromeContainers()
os.Exit(code)
}

func TestEphemeralCounterE2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping E2E test in short mode")
}

serverPort, err := e2etest.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port for server: %v", err)
}

debugPort, err := e2etest.GetFreePort()
if err != nil {
t.Fatalf("Failed to get free port for Chrome: %v", err)
}

serverCmd := e2etest.StartTestServer(t, "main.go", serverPort)
defer func() {
if serverCmd != nil && serverCmd.Process != nil {
serverCmd.Process.Kill()
}
}()

chromeCmd := e2etest.StartDockerChrome(t, debugPort)
defer e2etest.StopDockerChrome(t, debugPort)
_ = chromeCmd

chromeURL := fmt.Sprintf("http://localhost:%d", debugPort)
allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL)
defer allocCancel()

ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf))
defer cancel()

ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
defer cancel()

t.Run("Initial_Load", func(t *testing.T) {
var html string
err := chromedp.Run(ctx,
chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
e2etest.WaitForWebSocketReady(5*time.Second),
chromedp.WaitVisible(`h1`, chromedp.ByQuery),
e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"),
chromedp.OuterHTML(`body`, &html, chromedp.ByQuery),
)
if err != nil {
t.Fatalf("Failed to load page: %v", err)
}
if !strings.Contains(html, "Count: 0") {
t.Error("Expected initial Count: 0")
}
})

t.Run("Increment", func(t *testing.T) {
err := chromedp.Run(ctx,
e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second),
chromedp.Evaluate(`document.querySelector('button[name="increment"]').click()`, nil),
e2etest.WaitFor(`document.body.innerText.includes('Count: 1')`, 5*time.Second),
)
if err != nil {
t.Fatalf("Failed to increment: %v", err)
}
})

t.Run("Ephemeral_State_Survives_Refresh_Via_DB", func(t *testing.T) {
// Ephemeral mode: session state is NOT persisted, but the in-memory "DB"
// retains the count. On refresh, Mount() reloads from DB, so count = 1.
err := chromedp.Run(ctx,
chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)),
e2etest.WaitForWebSocketReady(5*time.Second),
chromedp.WaitVisible(`h1`, chromedp.ByQuery),
)
if err != nil {
t.Fatalf("Failed to reload: %v", err)
}

var html string
if err := chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)); err != nil {
t.Fatalf("Failed to get HTML: %v", err)
}

if !strings.Contains(html, "Count: 1") {
t.Errorf("Expected Count: 1 after refresh (loaded from DB), got: %s", html)
}
})
}
115 changes: 115 additions & 0 deletions ephemeral-counter/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"

"github.com/livetemplate/livetemplate"
e2etest "github.com/livetemplate/lvt/testing"
)

// CounterDB is a thread-safe in-memory counter that acts as the "database".
// With WithEphemeralState(), LiveTemplate state is rebuilt from this store
// on every request — no session persistence needed.
type CounterDB struct {
mu sync.Mutex
value int
}

func (db *CounterDB) Get() int {
db.mu.Lock()
defer db.mu.Unlock()
return db.value
}

func (db *CounterDB) Add(delta int) int {
db.mu.Lock()
defer db.mu.Unlock()
db.value += delta
return db.value
}

func (db *CounterDB) Reset() {
db.mu.Lock()
defer db.mu.Unlock()
db.value = 0
}

type CounterController struct {
DB *CounterDB
}

type CounterState struct {
Count int `json:"count"`
}

func (c *CounterController) Mount(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
state.Count = c.DB.Get()
return state, nil
}

func (c *CounterController) Increment(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
state.Count = c.DB.Add(1)
return state, nil
}

func (c *CounterController) Decrement(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
state.Count = c.DB.Add(-1)
return state, nil
}

func (c *CounterController) Reset(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
c.DB.Reset()
state.Count = 0
return state, nil
}

func main() {
controller := &CounterController{DB: &CounterDB{}}

tmpl := livetemplate.Must(livetemplate.New("counter"))
handler := tmpl.Handle(controller, livetemplate.AsState(&CounterState{}),
livetemplate.WithEphemeralState(),
)

mux := http.NewServeMux()
mux.Handle("/", handler)
mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)

port := os.Getenv("PORT")
if port == "" {
port = "8080"
}

server := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
}

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

go func() {
slog.Info("Server starting", "url", "http://localhost:"+port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("Server failed", "error", err)
os.Exit(1)
}
}()

<-quit

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
slog.Error("Shutdown error", "error", err)
}
}
Loading
Loading