readysite / pkg / frontend / frontend.go
7.9 KB
frontend.go
package frontend

import (
	"cmp"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"html/template"
	"net/http"
	"os"
	"path/filepath"
	"sync"

	"github.com/readysite/readysite/pkg/application"
	"github.com/readysite/readysite/pkg/frontend/esbuild"
)

// Frontend manages JavaScript component islands within HTMX-driven pages.
type Frontend struct {
	SourceDir string // Default: "components"
	OutputDir string // Default: "views/static/scripts/gen"
	DevMode   bool   // Enable HMR and file watching

	// Bundler configuration (optional, for WithBundler)
	bundler *esbuild.Bundler

	// HMR state
	mu       sync.Mutex
	clients  map[chan struct{}]struct{}
	buildErr error

	// File watcher (stored for cleanup)
	watcher interface{ Close() error }
}

// New creates a new Frontend instance.
func New() *Frontend {
	return &Frontend{
		SourceDir: "components",
		OutputDir: "views/static/scripts/gen",
		DevMode:   os.Getenv("ENV") != "production",
		clients:   make(map[chan struct{}]struct{}),
	}
}

// Script returns the runtime script tag for templates.
// This includes mount orchestration and HMR client in development.
func (f *Frontend) Script() template.HTML {
	hmr := ""
	if f.DevMode {
		hmr = `
// HMR in development
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
    const evtSource = new EventSource('/_frontend/hmr');
    evtSource.onmessage = () => location.reload();
    evtSource.onerror = () => setTimeout(() => location.reload(), 1000);
}
`
	}

	// Mount orchestration (framework-agnostic)
	// Components bundle must provide: __render(el, Component, props), __unmount(el), window.ComponentName
	orchestration := `
<script>
window.__renderAll = function() {
    document.querySelectorAll('[data-component]').forEach(el => {
        const name = el.dataset.component;
        const Component = window[name];
        if (!Component) {
            console.warn('[frontend] Component not found:', name);
            return;
        }
        el.innerHTML = '';
        __render(el, Component, JSON.parse(el.dataset.props || '{}'));
    });
};

// HTMX integration - use document instead of document.body to avoid null reference
// when script runs in <head> before body exists
document.addEventListener('htmx:beforeSwap', (e) => {
    e.detail.target.querySelectorAll('[data-component]').forEach(__unmount);
});
document.addEventListener('htmx:afterSwap', (e) => {
    e.detail.target.querySelectorAll('[data-component]').forEach(el => {
        const Component = window[el.dataset.component];
        if (Component) {
            el.innerHTML = '';
            __render(el, Component, JSON.parse(el.dataset.props || '{}'));
        }
    });
});

// Handle full page swaps (hx-boost)
document.addEventListener('htmx:afterSettle', (e) => {
    if (e.detail.requestConfig?.boosted) {
        __renderAll();
    }
});
</script>
`

	// Cache-busting hash from bundle content
	cacheBust := ""
	if data, err := os.ReadFile(filepath.Join(f.OutputDir, "components.js")); err == nil {
		h := sha256.Sum256(data)
		cacheBust = "?v=" + hex.EncodeToString(h[:4])
	}

	// Components bundle + trigger
	bundle := fmt.Sprintf(`<script type="module" src="/_frontend/components.js%s"></script>
<script type="module">
__renderAll();
%s
</script>`, cacheBust, hmr)

	return template.HTML(orchestration + bundle)
}

// Render returns an island container for the named component.
// Props are JSON-serialized into data-props attribute.
func (f *Frontend) Render(name string, props any) template.HTML {
	propsJSON := "{}"
	if props != nil {
		data, err := json.Marshal(props)
		if err == nil {
			propsJSON = string(data)
		}
	}

	// Use a skeleton placeholder while the component loads
	html := fmt.Sprintf(`<div data-component="%s" data-props='%s'>
    <div class="skeleton h-32 w-full"></div>
</div>`, template.HTMLEscapeString(name), template.HTMLEscapeString(propsJSON))

	return template.HTML(html)
}

// handleHMR handles SSE connections for hot module replacement.
func (f *Frontend) handleHMR(w http.ResponseWriter, r *http.Request) {
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "SSE not supported", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("Access-Control-Allow-Origin", "*")

	// Register client
	ch := make(chan struct{}, 1)
	f.mu.Lock()
	f.clients[ch] = struct{}{}
	f.mu.Unlock()

	// Send initial connection message
	fmt.Fprintf(w, "data: connected\n\n")
	flusher.Flush()

	// Wait for reload signal or disconnect
	select {
	case <-ch:
		fmt.Fprintf(w, "data: reload\n\n")
		flusher.Flush()
	case <-r.Context().Done():
	}

	// Unregister client
	f.mu.Lock()
	delete(f.clients, ch)
	f.mu.Unlock()
}

// handleComponents serves the compiled components bundle.
func (f *Frontend) handleComponents(w http.ResponseWriter, r *http.Request) {
	bundlePath := filepath.Join(f.OutputDir, "components.js")

	// Check if bundle exists
	if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
		// Try to build if source exists
		if _, srcErr := os.Stat(f.SourceDir); srcErr == nil {
			if buildErr := f.Build(); buildErr != nil {
				http.Error(w, "Build failed: "+buildErr.Error(), http.StatusInternalServerError)
				return
			}
		} else {
			// No source, serve empty module
			w.Header().Set("Content-Type", "application/javascript")
			w.Write([]byte("// No components found\nwindow.__render = () => {};\nwindow.__unmount = () => {};\n"))
			return
		}
	}

	w.Header().Set("Content-Type", "application/javascript")
	w.Header().Set("Cache-Control", cmp.Or(map[bool]string{true: "no-cache", false: "max-age=31536000"}[f.DevMode], "no-cache"))
	http.ServeFile(w, r, bundlePath)
}

// handleSourceMap serves the source map for debugging.
func (f *Frontend) handleSourceMap(w http.ResponseWriter, r *http.Request) {
	mapPath := filepath.Join(f.OutputDir, "components.js.map")
	if _, err := os.Stat(mapPath); os.IsNotExist(err) {
		http.NotFound(w, r)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	http.ServeFile(w, r, mapPath)
}

// notifyClients sends reload signal to all connected HMR clients.
func (f *Frontend) notifyClients() {
	f.mu.Lock()
	defer f.mu.Unlock()

	for ch := range f.clients {
		select {
		case ch <- struct{}{}:
		default:
		}
	}
}

// WithFrontend returns an application.Option that registers the frontend.
func WithFrontend() application.Option {
	f := New()

	// Register routes
	http.HandleFunc("GET /_frontend/hmr", f.handleHMR)
	http.HandleFunc("GET /_frontend/components.js", f.handleComponents)
	http.HandleFunc("GET /_frontend/components.js.map", f.handleSourceMap)

	return func(app *application.App) {
		app.Func("frontend_script", f.Script)
		app.Func("render", f.Render)
	}
}

// WithFrontendConfig returns an option with a pre-configured Frontend instance.
// Use this for advanced configuration.
func WithFrontendConfig(f *Frontend) application.Option {
	// Register routes
	http.HandleFunc("GET /_frontend/hmr", f.handleHMR)
	http.HandleFunc("GET /_frontend/components.js", f.handleComponents)
	http.HandleFunc("GET /_frontend/components.js.map", f.handleSourceMap)

	return func(app *application.App) {
		app.Func("frontend_script", f.Script)
		app.Func("render", f.Render)
	}
}

// WithBundler returns an application.Option that uses the esbuild bundler.
// This is the recommended way to configure the frontend for library mode.
//
// Example:
//
//	application.Serve(views,
//	    frontend.WithBundler(&esbuild.Config{
//	        Entry:   "components/index.ts",
//	        Include: []string{"components"},
//	    }),
//	)
func WithBundler(cfg *esbuild.Config) application.Option {
	f := New()

	// Default config if nil
	if cfg == nil {
		cfg = &esbuild.Config{}
	}

	// Set source dir from config (first Include directory, used for file watching)
	if len(cfg.Include) > 0 {
		f.SourceDir = cfg.Include[0]
	}

	// Create bundler
	f.bundler = esbuild.NewBundler(cfg, f.OutputDir, f.DevMode)

	return func(app *application.App) {
		app.Controller(f.Controller())
	}
}
← Back