frontend.go

86 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
// Package frontend bundles JavaScript component islands for HTMX-driven pages: an
// esbuild-backed bundler, hot module replacement over SSE in development, and
// server-side rendering of components into templates. It plugs into an
// application via WithBundler and adds no build step to production — the bundle
// is built once at boot and served from the embedded views. The HMR hub is a
// single goroutine that owns client state over channels, so no mutex is needed.
package frontend

import (
	"os"
	"sync"

	"congo.gg/dev/internal/frontend/esbuild"
	"congo.gg/pkg/application"
)

// 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
	buildMu sync.Mutex // serializes concurrent Build() calls

	// HMR broadcast hub — a single goroutine owns client state,
	// no mutex needed. Channels coordinate access.
	hmrJoin   chan chan struct{} // send a channel to subscribe
	hmrLeave  chan chan struct{} // send a channel to unsubscribe
	hmrReload chan struct{}      // signal all clients to reload
	hmrDone   chan struct{}      // closed on Stop() to shut down hub

	// File watcher (stored for cleanup)
	watcher  interface{ Close() error }
	stopOnce sync.Once
}

// New creates a new Frontend instance.
func New() *Frontend {
	f := &Frontend{
		SourceDir: "components",
		OutputDir: "views/static/scripts/gen",
		DevMode:   os.Getenv("ENV") != "production",
	}
	if f.DevMode {
		f.hmrJoin = make(chan chan struct{})
		f.hmrLeave = make(chan chan struct{})
		f.hmrReload = make(chan struct{}, 1)
		f.hmrDone = make(chan struct{})
		go f.hmrHub()
	}
	return f
}

// WithBundler returns an application.Option that uses the esbuild bundler.
// This is the recommended way to configure the frontend for library mode.
//
// Example:
//
//	application.New(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())
	}
}