diff --git a/README.md b/README.md index c7215dd..2f77311 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ephemeral-counter/counter.tmpl b/ephemeral-counter/counter.tmpl new file mode 100644 index 0000000..7796507 --- /dev/null +++ b/ephemeral-counter/counter.tmpl @@ -0,0 +1,31 @@ + + + + Ephemeral Counter + + + + + +
+
+
+

Ephemeral Counter

+

Database is the source of truth. State is rebuilt from DB on every page load.

+
+

Count: {{.Count}}

+
+ + + +
+
+
+ + {{if .lvt.DevMode}} + + {{else}} + + {{end}} + + diff --git a/ephemeral-counter/counter_test.go b/ephemeral-counter/counter_test.go new file mode 100644 index 0000000..b582133 --- /dev/null +++ b/ephemeral-counter/counter_test.go @@ -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) + } + }) +} diff --git a/ephemeral-counter/main.go b/ephemeral-counter/main.go new file mode 100644 index 0000000..65c022c --- /dev/null +++ b/ephemeral-counter/main.go @@ -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) + } +} diff --git a/ephemeral-todos/main.go b/ephemeral-todos/main.go new file mode 100644 index 0000000..0d7e559 --- /dev/null +++ b/ephemeral-todos/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "database/sql" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/livetemplate/livetemplate" + e2etest "github.com/livetemplate/lvt/testing" + _ "modernc.org/sqlite" +) + +type Todo struct { + ID int `json:"id"` + Title string `json:"title"` + Done bool `json:"done"` +} + +// TodoController holds the database dependency. +type TodoController struct { + DB *sql.DB +} + +// TodoState is rebuilt from the database on every request. +type TodoState struct { + Items []Todo `json:"items"` +} + +func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + rows, err := c.DB.Query("SELECT id, title, done FROM todos ORDER BY id") + if err != nil { + return state, err + } + defer rows.Close() + + state.Items = nil + for rows.Next() { + var t Todo + if err := rows.Scan(&t.ID, &t.Title, &t.Done); err != nil { + return state, err + } + state.Items = append(state.Items, t) + } + return state, nil +} + +func (c *TodoController) Submit(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + title := ctx.GetString("title") + if title == "" { + return state, livetemplate.FieldError{Field: "title", Message: "Title is required"} + } + + result, err := c.DB.Exec("INSERT INTO todos (title, done) VALUES (?, 0)", title) + if err != nil { + return state, err + } + + id, _ := result.LastInsertId() + state.Items = append(state.Items, Todo{ID: int(id), Title: title, Done: false}) + return state, nil +} + +func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + id, _ := strconv.Atoi(ctx.GetString("value")) + _, err := c.DB.Exec("UPDATE todos SET done = NOT done WHERE id = ?", id) + if err != nil { + return state, err + } + + for i := range state.Items { + if state.Items[i].ID == id { + state.Items[i].Done = !state.Items[i].Done + break + } + } + return state, nil +} + +func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) { + id, _ := strconv.Atoi(ctx.GetString("value")) + _, err := c.DB.Exec("DELETE FROM todos WHERE id = ?", id) + if err != nil { + return state, err + } + + for i := range state.Items { + if state.Items[i].ID == id { + state.Items = append(state.Items[:i], state.Items[i+1:]...) + break + } + } + return state, nil +} + +func initDB() *sql.DB { + db, err := sql.Open("sqlite", "file:todos.db?cache=shared&mode=rwc") + if err != nil { + slog.Error("Failed to open database", "error", err) + os.Exit(1) + } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + done BOOLEAN NOT NULL DEFAULT 0 + )`) + if err != nil { + slog.Error("Failed to create table", "error", err) + os.Exit(1) + } + return db +} + +func main() { + db := initDB() + defer db.Close() + + controller := &TodoController{DB: db} + + opts := []livetemplate.Option{} + if os.Getenv("LVT_WEBSOCKET_DISABLED") == "true" { + opts = append(opts, livetemplate.WithWebSocketDisabled()) + } + + tmpl := livetemplate.Must(livetemplate.New("todos", opts...)) + handler := tmpl.Handle(controller, livetemplate.AsState(&TodoState{}), + 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() + server.Shutdown(ctx) +} diff --git a/ephemeral-todos/todos.tmpl b/ephemeral-todos/todos.tmpl new file mode 100644 index 0000000..49329a9 --- /dev/null +++ b/ephemeral-todos/todos.tmpl @@ -0,0 +1,51 @@ + + + + Ephemeral Todos + + + + + +
+
+
+

Ephemeral Todos

+

Database is the source of truth. State is rebuilt from DB on every page load.

+
+ +
+
+ + +
+ {{if .lvt.HasError "title"}} + {{.lvt.Error "title"}} + {{end}} +
+ + {{if .Items}} + + {{else}} +

No todos yet. Add one above!

+ {{end}} +
+
+ + {{if .lvt.DevMode}} + + {{else}} + + {{end}} + + diff --git a/ephemeral-todos/todos_test.go b/ephemeral-todos/todos_test.go new file mode 100644 index 0000000..e36efe3 --- /dev/null +++ b/ephemeral-todos/todos_test.go @@ -0,0 +1,108 @@ +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 TestEphemeralTodosE2E(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_Empty", 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, "No todos yet") { + t.Error("Expected empty state message") + } + }) + + t.Run("Add_Todo", func(t *testing.T) { + err := chromedp.Run(ctx, + e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second), + chromedp.SetValue(`input[name="title"]`, "Buy groceries", chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('button[type="submit"]').click()`, nil), + e2etest.WaitFor(`document.body.innerText.includes('Buy groceries')`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to add todo: %v", err) + } + }) + + t.Run("Todo_Survives_Refresh_Via_DB", func(t *testing.T) { + // Ephemeral mode: session state is NOT persisted, but SQLite retains the + // todo. On refresh, Mount() reloads from DB. + 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, "Buy groceries") { + t.Errorf("Expected 'Buy groceries' after refresh (loaded from DB), got: %s", html) + } + }) +} diff --git a/go.mod b/go.mod index 9a8b90c..210b8f4 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-playground/validator/v10 v10.30.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/livetemplate/livetemplate v0.8.10 + github.com/livetemplate/livetemplate v0.8.11 github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e modernc.org/sqlite v1.43.0 diff --git a/go.sum b/go.sum index c7103c2..320bcde 100644 --- a/go.sum +++ b/go.sum @@ -94,6 +94,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/livetemplate/livetemplate v0.8.10 h1:Lg62gb297Iq3/EShPnEUyqDFN8aeyTYchbW3wUFU370= github.com/livetemplate/livetemplate v0.8.10/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.8.11 h1:s7yFfHp53tv5W7WNViQUK7EBq6ZmBth+mGA34+/zL9s= +github.com/livetemplate/livetemplate v0.8.11/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e h1:nAV7BaOatFcbSaP6m9CgLrRsGPeWtbjPDZ8dOI6Zb+c= github.com/livetemplate/lvt v0.0.0-20260327182801-53d6d40e692e/go.mod h1:17cFl500ntymD3gx8h+ZODnVnTictHgG8Wmz/By75sU= github.com/livetemplate/lvt/components v0.0.0-20260327182801-53d6d40e692e h1:vuR0pQtEQHZOD2/HvTJfHPKEdoD77XUs1mq1kdjgVig= diff --git a/test-all.sh b/test-all.sh index ae85c0d..33a3a9d 100755 --- a/test-all.sh +++ b/test-all.sh @@ -36,6 +36,8 @@ WORKING_EXAMPLES=( "todos-components" "shared-notepad" "flash-messages" + "ephemeral-counter" + "ephemeral-todos" ) # Disabled examples