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())
}
}