feat(nebula): add core middleware, mount, and config packages

This commit is contained in:
2026-01-07 21:09:25 -05:00
parent 51b5d57222
commit 6b9935ddbe
11 changed files with 676 additions and 0 deletions

29
cmd/server/main.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"fmt"
"log"
"net/http"
"nebula"
)
func main() {
mux := http.NewServeMux()
// Register application routes
registerRoutes(mux)
// Apply nebula middleware
handler := nebula.Middleware(
nebula.WithBasePath("/"),
nebula.WithAppTitle("Sonr Wallet"),
nebula.WithTheme("dark"),
nebula.WithTransitions(true),
nebula.WithPreload(true),
)(mux)
addr := ":8080"
fmt.Printf("Starting server at http://localhost%s\n", addr)
log.Fatal(http.ListenAndServe(addr, handler))
}

176
cmd/server/routes.go Normal file
View File

@@ -0,0 +1,176 @@
package main
import (
"net/http"
"strconv"
"nebula/models"
"nebula/views"
)
func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", handleWelcome)
mux.HandleFunc("GET /welcome", handleWelcome)
mux.HandleFunc("GET /welcome/step/{step}", handleWelcomeStep)
mux.HandleFunc("GET /register", handleRegister)
mux.HandleFunc("GET /register/step/{step}", handleRegisterStep)
mux.HandleFunc("GET /register/capabilities", handleRegisterCapabilities)
mux.HandleFunc("POST /register/verify-code", handleRegisterVerifyCode)
mux.HandleFunc("GET /login", handleLogin)
mux.HandleFunc("GET /login/step/{step}", handleLoginStep)
mux.HandleFunc("GET /login/qr-status", handleLoginQRStatus)
mux.HandleFunc("GET /authorize", handleAuthorize)
mux.HandleFunc("POST /authorize/approve", handleAuthorizeApprove)
mux.HandleFunc("POST /authorize/deny", handleAuthorizeDeny)
mux.HandleFunc("GET /dashboard", handleDashboard)
mux.HandleFunc("GET /settings", handleSettings)
}
// handleWelcome renders the full welcome page at step 1
func handleWelcome(w http.ResponseWriter, r *http.Request) {
views.WelcomePage(1).Render(r.Context(), w)
}
// handleWelcomeStep handles HTMX partial updates for step navigation
func handleWelcomeStep(w http.ResponseWriter, r *http.Request) {
stepStr := r.PathValue("step")
step, err := strconv.Atoi(stepStr)
if err != nil || step < 1 || step > 3 {
step = 1
}
// Check if this is an HTMX request
if r.Header.Get("HX-Request") == "true" {
// Return step content with OOB stepper update (HTMX 4 pattern)
views.WelcomeStepWithStepper(step).Render(r.Context(), w)
return
}
views.WelcomePage(step).Render(r.Context(), w)
}
func handleRegister(w http.ResponseWriter, r *http.Request) {
state := models.RegisterState{Step: 1}
views.RegisterPage(state).Render(r.Context(), w)
}
func handleRegisterStep(w http.ResponseWriter, r *http.Request) {
stepStr := r.PathValue("step")
step, err := strconv.Atoi(stepStr)
if err != nil || step < 1 || step > 3 {
step = 1
}
method := r.URL.Query().Get("method")
if method == "" {
method = "passkey"
}
state := models.RegisterState{Step: step, Method: method}
if r.Header.Get("HX-Request") == "true" {
views.RegisterStepWithStepper(state).Render(r.Context(), w)
return
}
views.RegisterPage(state).Render(r.Context(), w)
}
func handleRegisterCapabilities(w http.ResponseWriter, r *http.Request) {
caps := models.DeviceCapabilities{
Platform: true,
CrossPlatform: true,
Conditional: true,
}
views.CapabilitiesResult(caps).Render(r.Context(), w)
}
func handleRegisterVerifyCode(w http.ResponseWriter, r *http.Request) {
state := models.RegisterState{Step: 3}
if r.Header.Get("HX-Request") == "true" {
views.RegisterStepWithStepper(state).Render(r.Context(), w)
return
}
views.RegisterPage(state).Render(r.Context(), w)
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
state := models.LoginState{Step: "1"}
views.LoginPage(state).Render(r.Context(), w)
}
func handleLoginStep(w http.ResponseWriter, r *http.Request) {
step := r.PathValue("step")
if step == "" {
step = "1"
}
state := models.LoginState{Step: step}
if r.Header.Get("HX-Request") == "true" {
views.LoginStepWithOOB(state).Render(r.Context(), w)
return
}
views.LoginPage(state).Render(r.Context(), w)
}
var qrPollCount = 0
func handleLoginQRStatus(w http.ResponseWriter, r *http.Request) {
qrPollCount++
if qrPollCount >= 3 {
qrPollCount = 0
views.QRStatusSuccess().Render(r.Context(), w)
return
}
views.QRStatusWaiting().Render(r.Context(), w)
}
func handleAuthorize(w http.ResponseWriter, r *http.Request) {
reqType := r.URL.Query().Get("type")
req := models.DefaultAuthRequest(reqType)
views.AuthorizePage(req).Render(r.Context(), w)
}
func handleAuthorizeApprove(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
actionType := r.FormValue("type")
if actionType == "" {
actionType = "connect"
}
views.AuthResultSuccess(actionType).Render(r.Context(), w)
}
func handleAuthorizeDeny(w http.ResponseWriter, r *http.Request) {
views.AuthResultDenied().Render(r.Context(), w)
}
func handleDashboard(w http.ResponseWriter, r *http.Request) {
tab := r.URL.Query().Get("tab")
if tab == "" {
tab = "overview"
}
data := models.DefaultDashboardData()
if r.Header.Get("HX-Request") == "true" {
views.DashboardContent(data, tab).Render(r.Context(), w)
return
}
views.DashboardPage(data, tab).Render(r.Context(), w)
}
func handleSettings(w http.ResponseWriter, r *http.Request) {
tab := r.URL.Query().Get("tab")
if tab == "" {
tab = "profile"
}
data := models.DefaultSettingsData()
views.SettingsPage(data, tab).Render(r.Context(), w)
}

19
config.go Normal file
View File

@@ -0,0 +1,19 @@
package nebula
import (
"context"
"nebula/pkg/config"
)
type Config = config.Config
type HTMXConfig = config.HTMXConfig
type SSEConfig = config.SSEConfig
func DefaultConfig() Config {
return config.Default()
}
func ConfigFromContext(ctx context.Context) *Config {
return config.FromContext(ctx)
}

36
htmx/context.go Normal file
View File

@@ -0,0 +1,36 @@
package htmx
import "context"
type contextKey string
const htmxContextKey contextKey = "htmx"
type Context struct {
IsHTMX bool
IsPartial bool
IsBoosted bool
Target string
Trigger string
TriggerID string
CurrentURL string
}
func WithContext(ctx context.Context, hc *Context) context.Context {
return context.WithValue(ctx, htmxContextKey, hc)
}
func FromContext(ctx context.Context) *Context {
if hc, ok := ctx.Value(htmxContextKey).(*Context); ok {
return hc
}
return &Context{}
}
func IsHTMXRequest(ctx context.Context) bool {
return FromContext(ctx).IsHTMX
}
func IsPartialRequest(ctx context.Context) bool {
return FromContext(ctx).IsPartial
}

51
htmx/request.go Normal file
View File

@@ -0,0 +1,51 @@
package htmx
import "net/http"
func IsRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
func IsPartial(r *http.Request) bool {
return r.Header.Get("HX-Request-Type") == "partial"
}
func IsBoosted(r *http.Request) bool {
return r.Header.Get("HX-Boosted") == "true"
}
func IsHistoryRestore(r *http.Request) bool {
return r.Header.Get("HX-History-Restore-Request") == "true"
}
func GetTarget(r *http.Request) string {
return r.Header.Get("HX-Target")
}
func GetTrigger(r *http.Request) string {
return r.Header.Get("HX-Trigger")
}
func GetTriggerName(r *http.Request) string {
return r.Header.Get("HX-Trigger-Name")
}
func GetCurrentURL(r *http.Request) string {
return r.Header.Get("HX-Current-URL")
}
func GetPromptResponse(r *http.Request) string {
return r.Header.Get("HX-Prompt")
}
func ExtractContext(r *http.Request) *Context {
return &Context{
IsHTMX: IsRequest(r),
IsPartial: IsPartial(r),
IsBoosted: IsBoosted(r),
Target: GetTarget(r),
Trigger: GetTrigger(r),
TriggerID: GetTrigger(r),
CurrentURL: GetCurrentURL(r),
}
}

103
htmx/response.go Normal file
View File

@@ -0,0 +1,103 @@
package htmx
import (
"encoding/json"
"net/http"
)
type Response struct {
w http.ResponseWriter
}
func NewResponse(w http.ResponseWriter) *Response {
return &Response{w: w}
}
func (r *Response) Header() http.Header {
return r.w.Header()
}
func (r *Response) Location(url string) *Response {
r.w.Header().Set("HX-Location", url)
return r
}
func (r *Response) LocationWithTarget(url, target string) *Response {
data, _ := json.Marshal(map[string]string{"path": url, "target": target})
r.w.Header().Set("HX-Location", string(data))
return r
}
func (r *Response) PushURL(url string) *Response {
r.w.Header().Set("HX-Push-Url", url)
return r
}
func (r *Response) ReplaceURL(url string) *Response {
r.w.Header().Set("HX-Replace-Url", url)
return r
}
func (r *Response) Redirect(url string) *Response {
r.w.Header().Set("HX-Redirect", url)
return r
}
func (r *Response) Refresh() *Response {
r.w.Header().Set("HX-Refresh", "true")
return r
}
func (r *Response) Reswap(strategy string) *Response {
r.w.Header().Set("HX-Reswap", strategy)
return r
}
func (r *Response) ReswapWithModifiers(strategy string) *Response {
r.w.Header().Set("HX-Reswap", strategy)
return r
}
func (r *Response) Retarget(selector string) *Response {
r.w.Header().Set("HX-Retarget", selector)
return r
}
func (r *Response) Reselect(selector string) *Response {
r.w.Header().Set("HX-Reselect", selector)
return r
}
func (r *Response) Trigger(event string) *Response {
r.w.Header().Set("HX-Trigger", event)
return r
}
func (r *Response) TriggerWithData(event string, data any) *Response {
payload, _ := json.Marshal(map[string]any{event: data})
r.w.Header().Set("HX-Trigger", string(payload))
return r
}
func (r *Response) TriggerAfterSwap(event string) *Response {
r.w.Header().Set("HX-Trigger-After-Swap", event)
return r
}
func (r *Response) TriggerAfterSettle(event string) *Response {
r.w.Header().Set("HX-Trigger-After-Settle", event)
return r
}
func (r *Response) StopPolling() *Response {
r.w.WriteHeader(286)
return r
}
func (r *Response) NoContent() {
r.w.WriteHeader(http.StatusNoContent)
}
func (r *Response) PreventSwap() *Response {
return r.Reswap("none")
}

38
middleware.go Normal file
View File

@@ -0,0 +1,38 @@
package nebula
import (
"net/http"
"nebula/htmx"
"nebula/pkg/config"
)
func Middleware(opts ...Option) func(http.Handler) http.Handler {
cfg := DefaultConfig()
for _, opt := range opts {
opt(&cfg)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hxCtx := htmx.ExtractContext(r)
ctx := htmx.WithContext(r.Context(), hxCtx)
ctx = config.WithContext(ctx, &cfg)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func SSEMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "text/event-stream" {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
}
next.ServeHTTP(w, r)
})
}
}

40
mount.go Normal file
View File

@@ -0,0 +1,40 @@
package nebula
import (
"net/http"
)
// Handler creates a new http.Handler with nebula middleware applied.
// The handler parameter is wrapped with HTMX context and config middleware.
func Handler(handler http.Handler, opts ...Option) http.Handler {
cfg := DefaultConfig()
for _, opt := range opts {
opt(&cfg)
}
var h http.Handler = handler
h = Middleware(opts...)(h)
if cfg.SSE.Enabled {
h = SSEMiddleware()(h)
}
return h
}
// Mount registers a handler at the configured base path on the provided mux.
// Use this to mount your application routes with nebula middleware.
func Mount(mux *http.ServeMux, handler http.Handler, opts ...Option) {
cfg := DefaultConfig()
for _, opt := range opts {
opt(&cfg)
}
h := Handler(handler, opts...)
if cfg.BasePath == "/" {
mux.Handle("/", h)
} else {
mux.Handle(cfg.BasePath+"/", http.StripPrefix(cfg.BasePath, h))
}
}

78
options.go Normal file
View File

@@ -0,0 +1,78 @@
package nebula
type Option func(*Config)
func WithBasePath(path string) Option {
return func(c *Config) {
c.BasePath = path
}
}
func WithAppTitle(title string) Option {
return func(c *Config) {
c.AppTitle = title
}
}
func WithTheme(theme string) Option {
return func(c *Config) {
c.Theme = theme
}
}
func WithTransitions(enabled bool) Option {
return func(c *Config) {
c.HTMX.Transitions = enabled
}
}
func WithImplicitInheritance(enabled bool) Option {
return func(c *Config) {
c.HTMX.ImplicitInheritance = enabled
}
}
func WithSelfRequestsOnly(enabled bool) Option {
return func(c *Config) {
c.HTMX.SelfRequestsOnly = enabled
}
}
func WithTimeout(ms int) Option {
return func(c *Config) {
c.HTMX.Timeout = ms
}
}
func WithNoSwap(codes ...string) Option {
return func(c *Config) {
c.HTMX.NoSwap = codes
}
}
func WithSSE(enabled bool) Option {
return func(c *Config) {
c.SSE.Enabled = enabled
}
}
func WithSSEReconnect(enabled bool, delay, maxDelay, maxAttempts int) Option {
return func(c *Config) {
c.SSE.Reconnect = enabled
c.SSE.ReconnectDelay = delay
c.SSE.ReconnectMaxDelay = maxDelay
c.SSE.ReconnectMaxAttempts = maxAttempts
}
}
func WithPreload(enabled bool) Option {
return func(c *Config) {
c.Preload = enabled
}
}
func WithMorphing(enabled bool) Option {
return func(c *Config) {
c.Morphing = enabled
}
}

106
pkg/config/config.go Normal file
View File

@@ -0,0 +1,106 @@
package config
import (
"context"
"encoding/json"
"fmt"
)
type contextKey struct{}
type Config struct {
BasePath string
AppTitle string
Theme string
HTMX HTMXConfig
SSE SSEConfig
Preload bool
Morphing bool
}
type HTMXConfig struct {
Transitions bool
ImplicitInheritance bool
SelfRequestsOnly bool
Timeout int
NoSwap []string
}
type SSEConfig struct {
Enabled bool
Reconnect bool
ReconnectDelay int
ReconnectMaxDelay int
ReconnectMaxAttempts int
ReconnectJitter float64
PauseInBackground bool
}
func Default() Config {
return Config{
BasePath: "/",
AppTitle: "Sonr Wallet",
Theme: "dark",
HTMX: HTMXConfig{
Transitions: true,
ImplicitInheritance: false,
SelfRequestsOnly: true,
Timeout: 0,
NoSwap: []string{"204", "304"},
},
SSE: SSEConfig{
Enabled: true,
Reconnect: true,
ReconnectDelay: 500,
ReconnectMaxDelay: 60000,
ReconnectMaxAttempts: 10,
ReconnectJitter: 0.3,
PauseInBackground: false,
},
Preload: true,
Morphing: true,
}
}
func WithContext(ctx context.Context, cfg *Config) context.Context {
return context.WithValue(ctx, contextKey{}, cfg)
}
func FromContext(ctx context.Context) *Config {
if cfg, ok := ctx.Value(contextKey{}).(*Config); ok {
return cfg
}
defaultCfg := Default()
return &defaultCfg
}
func (c *Config) HTMXConfigJSON() string {
cfg := map[string]any{
"transitions": c.HTMX.Transitions,
"implicitInheritance": c.HTMX.ImplicitInheritance,
"selfRequestsOnly": c.HTMX.SelfRequestsOnly,
"timeout": c.HTMX.Timeout,
"noSwap": c.HTMX.NoSwap,
}
if c.SSE.Enabled {
cfg["streams"] = map[string]any{
"reconnect": c.SSE.Reconnect,
"reconnectDelay": c.SSE.ReconnectDelay,
"reconnectMaxDelay": c.SSE.ReconnectMaxDelay,
"reconnectMaxAttempts": c.SSE.ReconnectMaxAttempts,
"reconnectJitter": c.SSE.ReconnectJitter,
"pauseInBackground": c.SSE.PauseInBackground,
}
}
data, err := json.Marshal(cfg)
if err != nil {
return "{}"
}
return string(data)
}
func (c *Config) ThemeClass() string {
return fmt.Sprintf("wa-theme-%s", c.Theme)
}

BIN
server Executable file

Binary file not shown.