frontend.go
86 lines1
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())
}
}