2 Commits

Author SHA1 Message Date
Ryan Hamamura
d1e8e3a2ed feat: add OnKeyDownMap and WithWindow for combined key bindings
OnKeyDownMap merges multiple key bindings into a single
data-on:keydown__window attribute via a JS ternary chain,
avoiding HTML attribute deduplication. WithWindow scopes
any keydown listener to the window object.
2026-02-02 08:23:06 -10:00
Ryan Hamamura
4a7acbb630 feat: add graceful shutdown with OS signal handling
Handle SIGINT/SIGTERM in Start() to cleanly drain all contexts,
stop goroutines, close SSE connections, and tear down PubSub.

Fix stopAllRoutines() to close() the channel instead of sending a
single value, so all listening goroutines are notified.
2026-01-31 09:22:43 -10:00
3 changed files with 135 additions and 12 deletions

View File

@@ -3,6 +3,7 @@ package via
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -21,8 +22,16 @@ type triggerOpts struct {
hasSignal bool hasSignal bool
signalID string signalID string
value string value string
window bool
} }
type withWindowOpt struct{}
func (o withWindowOpt) apply(opts *triggerOpts) { opts.window = true }
// WithWindow scopes the event listener to the window instead of the element.
func WithWindow() ActionTriggerOption { return withWindowOpt{} }
type withSignalOpt struct { type withSignalOpt struct {
signalID string signalID string
value string value string
@@ -92,5 +101,34 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
if key != "" { if key != "" {
condition = fmt.Sprintf("evt.key==='%s' &&", key) condition = fmt.Sprintf("evt.key==='%s' &&", key)
} }
return h.Data("on:keydown", fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts))) attrName := "on:keydown"
if opts.window {
attrName = "on:keydown__window"
}
return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
}
// KeyBinding pairs a key name with action trigger options for use with OnKeyDownMap.
type KeyBinding struct {
Key string
Options []ActionTriggerOption
}
// KeyBind creates a KeyBinding for use with OnKeyDownMap.
func KeyBind(key string, options ...ActionTriggerOption) KeyBinding {
return KeyBinding{Key: key, Options: options}
}
// OnKeyDownMap combines multiple key bindings into a single data-on:keydown__window
// attribute using a JS ternary chain. This avoids HTML attribute deduplication issues
// that occur when multiple OnKeyDown calls target the same element.
func (a *actionTrigger) OnKeyDownMap(bindings ...KeyBinding) h.H {
var parts []string
for _, b := range bindings {
opts := applyOptions(b.Options...)
expr := buildOnExpr(actionURL(a.id), &opts)
parts = append(parts, fmt.Sprintf("evt.key==='%s' ? (%s)", b.Key, expr))
}
combined := strings.Join(parts, " : ") + " : void 0"
return h.Data("on:keydown__window", combined)
} }

View File

@@ -32,6 +32,7 @@ type Context struct {
reqCtx context.Context reqCtx context.Context
subscriptions []Subscription subscriptions []Subscription
subsMu sync.Mutex subsMu sync.Mutex
disposeOnce sync.Once
} }
// View defines the UI rendered by this context. // View defines the UI rendered by this context.
@@ -350,11 +351,23 @@ func (c *Context) ReplaceURLf(format string, a ...any) {
c.ReplaceURL(fmt.Sprintf(format, a...)) c.ReplaceURL(fmt.Sprintf(format, a...))
} }
// stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks. // dispose idempotently tears down this context: unsubscribes all pubsub
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
func (c *Context) dispose() {
c.disposeOnce.Do(func() {
c.unsubscribeAll()
c.stopAllRoutines()
})
}
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
// goroutines (OnIntervalRoutine, SSE loop) that this context is done.
func (c *Context) stopAllRoutines() { func (c *Context) stopAllRoutines() {
select { select {
case c.ctxDisposedChan <- struct{}{}: case <-c.ctxDisposedChan:
// already closed
default: default:
close(c.ctxDisposedChan)
} }
} }

90
via.go
View File

@@ -7,6 +7,7 @@
package via package via
import ( import (
"context"
"crypto/rand" "crypto/rand"
_ "embed" _ "embed"
"encoding/hex" "encoding/hex"
@@ -16,9 +17,12 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
ossignal "os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"syscall"
"time"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -34,6 +38,7 @@ var datastarJS []byte
type V struct { type V struct {
cfg Options cfg Options
mux *http.ServeMux mux *http.ServeMux
server *http.Server
logger zerolog.Logger logger zerolog.Logger
contextRegistry map[string]*Context contextRegistry map[string]*Context
contextRegistryMutex sync.RWMutex contextRegistryMutex sync.RWMutex
@@ -254,14 +259,82 @@ func (v *V) getCtx(id string) (*Context, error) {
return nil, fmt.Errorf("ctx '%s' not found", id) return nil, fmt.Errorf("ctx '%s' not found", id)
} }
// Start starts the Via HTTP server on the given address. // Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
// signal is received, then performs a graceful shutdown.
func (v *V) Start() { func (v *V) Start() {
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
handler := http.Handler(v.mux) handler := http.Handler(v.mux)
if v.sessionManager != nil { if v.sessionManager != nil {
handler = v.sessionManager.LoadAndSave(v.mux) handler = v.sessionManager.LoadAndSave(v.mux)
} }
v.logger.Fatal().Err(http.ListenAndServe(v.cfg.ServerAddress, handler)).Msg("http server failed") v.server = &http.Server{
Addr: v.cfg.ServerAddress,
Handler: handler,
}
errCh := make(chan error, 1)
go func() {
errCh <- v.server.ListenAndServe()
}()
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
sigCh := make(chan os.Signal, 1)
ossignal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
v.logInfo(nil, "received signal %v, shutting down", sig)
case err := <-errCh:
if err != nil && err != http.ErrServerClosed {
v.logger.Fatal().Err(err).Msg("http server failed")
}
return
}
v.shutdown()
}
// Shutdown gracefully shuts down the server and all contexts.
// Safe for programmatic or test use.
func (v *V) Shutdown() {
v.shutdown()
}
func (v *V) shutdown() {
v.logInfo(nil, "draining all contexts")
v.drainAllContexts()
if v.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := v.server.Shutdown(ctx); err != nil {
v.logErr(nil, "http server shutdown error: %v", err)
}
}
if v.pubsub != nil {
if err := v.pubsub.Close(); err != nil {
v.logErr(nil, "pubsub close error: %v", err)
}
}
v.logInfo(nil, "shutdown complete")
}
func (v *V) drainAllContexts() {
v.contextRegistryMutex.Lock()
contexts := make([]*Context, 0, len(v.contextRegistry))
for _, c := range v.contextRegistry {
contexts = append(contexts, c)
}
v.contextRegistry = make(map[string]*Context)
v.contextRegistryMutex.Unlock()
for _, c := range contexts {
v.logDebug(c, "disposing context")
c.dispose()
}
v.logInfo(nil, "drained %d context(s)", len(contexts))
} }
// HTTPServeMux returns the underlying HTTP request multiplexer to enable user extentions, middleware and // HTTPServeMux returns the underlying HTTP request multiplexer to enable user extentions, middleware and
@@ -445,10 +518,10 @@ func New() *V {
case <-sse.Context().Done(): case <-sse.Context().Done():
v.logDebug(c, "SSE connection ended") v.logDebug(c, "SSE connection ended")
return return
case patch, ok := <-c.patchChan: case <-c.ctxDisposedChan:
if !ok { v.logDebug(c, "context disposed, closing SSE")
continue return
} case patch := <-c.patchChan:
switch patch.typ { switch patch.typ {
case patchTypeElements: case patchTypeElements:
if err := sse.PatchElements(patch.content); err != nil { if err := sse.PatchElements(patch.content); err != nil {
@@ -530,8 +603,7 @@ func New() *V {
v.logErr(c, "failed to handle session close: %v", err) v.logErr(c, "failed to handle session close: %v", err)
return return
} }
c.unsubscribeAll() c.dispose()
c.stopAllRoutines()
v.logDebug(c, "session close event triggered") v.logDebug(c, "session close event triggered")
if v.cfg.DevMode { if v.cfg.DevMode {
v.devModeRemovePersisted(c) v.devModeRemovePersisted(c)