Skip to content

Commit fe8512f

Browse files
committed
feat: hot reload with live patching
1 parent feae1f4 commit fe8512f

File tree

13 files changed

+604
-6
lines changed

13 files changed

+604
-6
lines changed

cmd/rfw/server/hmr.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package server
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"path/filepath"
8+
"time"
9+
10+
"github.com/rfwlab/rfw/cmd/rfw/utils"
11+
)
12+
13+
type devMessage struct {
14+
Type string `json:"type"`
15+
Path string `json:"path,omitempty"`
16+
Component string `json:"component,omitempty"`
17+
Markup string `json:"markup,omitempty"`
18+
}
19+
20+
func (s *Server) handleHMR(w http.ResponseWriter, r *http.Request) {
21+
flusher, ok := w.(http.Flusher)
22+
if !ok {
23+
http.Error(w, "stream unsupported", http.StatusInternalServerError)
24+
return
25+
}
26+
27+
w.Header().Set("Content-Type", "text/event-stream")
28+
w.Header().Set("Cache-Control", "no-cache")
29+
w.Header().Set("Connection", "keep-alive")
30+
31+
client := make(chan []byte, 8)
32+
33+
s.hmrMu.Lock()
34+
s.hmrClients[client] = struct{}{}
35+
s.hmrMu.Unlock()
36+
37+
utils.Debug("hmr client connected")
38+
39+
fmt.Fprintf(w, ": connected\n\n")
40+
flusher.Flush()
41+
42+
defer func() {
43+
s.hmrMu.Lock()
44+
delete(s.hmrClients, client)
45+
s.hmrMu.Unlock()
46+
utils.Debug("hmr client disconnected")
47+
}()
48+
49+
ping := time.NewTicker(30 * time.Second)
50+
defer ping.Stop()
51+
52+
for {
53+
select {
54+
case msg, ok := <-client:
55+
if !ok {
56+
return
57+
}
58+
fmt.Fprintf(w, "data: %s\n\n", msg)
59+
flusher.Flush()
60+
case <-ping.C:
61+
fmt.Fprintf(w, ": ping\n\n")
62+
flusher.Flush()
63+
case <-r.Context().Done():
64+
return
65+
}
66+
}
67+
}
68+
69+
func (s *Server) broadcast(msg devMessage) error {
70+
data, err := json.Marshal(msg)
71+
if err != nil {
72+
return err
73+
}
74+
75+
s.hmrMu.Lock()
76+
defer s.hmrMu.Unlock()
77+
if len(s.hmrClients) == 0 {
78+
return nil
79+
}
80+
for ch := range s.hmrClients {
81+
select {
82+
case ch <- data:
83+
default:
84+
// Drop slow clients to avoid blocking rebuilds.
85+
delete(s.hmrClients, ch)
86+
close(ch)
87+
}
88+
}
89+
return nil
90+
}
91+
92+
func (s *Server) broadcastReload(path string) error {
93+
rel := path
94+
if abs, err := filepath.Abs(path); err == nil {
95+
if cwd, err := filepath.Abs("."); err == nil {
96+
if r, err := filepath.Rel(cwd, abs); err == nil {
97+
rel = filepath.ToSlash(r)
98+
}
99+
}
100+
}
101+
utils.Debug(fmt.Sprintf("broadcasting reload for %s", rel))
102+
return s.broadcast(devMessage{Type: "reload", Path: rel})
103+
}
104+
105+
func (s *Server) broadcastTemplateUpdate(path, component, markup string) error {
106+
rel := path
107+
if abs, err := filepath.Abs(path); err == nil {
108+
if cwd, err := filepath.Abs("."); err == nil {
109+
if r, err := filepath.Rel(cwd, abs); err == nil {
110+
rel = filepath.ToSlash(r)
111+
}
112+
}
113+
}
114+
utils.Debug(fmt.Sprintf("streaming template update for %s (%s)", rel, component))
115+
return s.broadcast(devMessage{Type: "rtml", Path: rel, Component: component, Markup: markup})
116+
}

cmd/rfw/server/hmr_template.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package server
2+
3+
import (
4+
"go/ast"
5+
"go/parser"
6+
"go/token"
7+
"os"
8+
"path/filepath"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
func componentNamesForTemplate(templatePath string) []string {
14+
abs := templatePath
15+
if v, err := filepath.Abs(templatePath); err == nil {
16+
abs = v
17+
}
18+
dir := filepath.Dir(abs)
19+
if filepath.Base(dir) == "templates" {
20+
dir = filepath.Dir(dir)
21+
}
22+
entries, err := os.ReadDir(dir)
23+
if err != nil {
24+
return nil
25+
}
26+
rel, err := filepath.Rel(dir, abs)
27+
if err != nil {
28+
rel = filepath.Base(abs)
29+
}
30+
rel = filepath.ToSlash(rel)
31+
var names []string
32+
seen := map[string]struct{}{}
33+
for _, entry := range entries {
34+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
35+
continue
36+
}
37+
goFile := filepath.Join(dir, entry.Name())
38+
fileNames := embedVariablesForTemplate(goFile, rel)
39+
if len(fileNames) == 0 {
40+
continue
41+
}
42+
comps := componentsUsingTemplates(goFile, fileNames)
43+
for _, name := range comps {
44+
if _, ok := seen[name]; ok {
45+
continue
46+
}
47+
seen[name] = struct{}{}
48+
names = append(names, name)
49+
}
50+
}
51+
if len(names) == 0 {
52+
return nil
53+
}
54+
return names
55+
}
56+
57+
func embedVariablesForTemplate(goFile, rel string) map[string]struct{} {
58+
fset := token.NewFileSet()
59+
file, err := parser.ParseFile(fset, goFile, nil, parser.ParseComments)
60+
if err != nil {
61+
return nil
62+
}
63+
vars := map[string]struct{}{}
64+
for _, decl := range file.Decls {
65+
gen, ok := decl.(*ast.GenDecl)
66+
if !ok || gen.Tok != token.VAR {
67+
continue
68+
}
69+
for _, spec := range gen.Specs {
70+
vs, ok := spec.(*ast.ValueSpec)
71+
if !ok {
72+
continue
73+
}
74+
if !specEmbedsPath(rel, gen.Doc, vs.Doc, vs.Comment) {
75+
continue
76+
}
77+
for _, name := range vs.Names {
78+
vars[name.Name] = struct{}{}
79+
}
80+
}
81+
}
82+
return vars
83+
}
84+
85+
func specEmbedsPath(rel string, groups ...*ast.CommentGroup) bool {
86+
for _, grp := range groups {
87+
if grp == nil {
88+
continue
89+
}
90+
for _, comment := range grp.List {
91+
text := strings.TrimSpace(comment.Text)
92+
if !strings.HasPrefix(text, "//go:embed") {
93+
continue
94+
}
95+
fields := strings.Fields(strings.TrimPrefix(text, "//go:embed"))
96+
for _, field := range fields {
97+
candidate := strings.Trim(field, "`\"")
98+
if candidate == "" {
99+
continue
100+
}
101+
if filepath.ToSlash(candidate) == rel {
102+
return true
103+
}
104+
}
105+
}
106+
}
107+
return false
108+
}
109+
110+
func componentsUsingTemplates(goFile string, vars map[string]struct{}) []string {
111+
fset := token.NewFileSet()
112+
file, err := parser.ParseFile(fset, goFile, nil, 0)
113+
if err != nil {
114+
return nil
115+
}
116+
var names []string
117+
ast.Inspect(file, func(n ast.Node) bool {
118+
call, ok := n.(*ast.CallExpr)
119+
if !ok || len(call.Args) < 2 {
120+
return true
121+
}
122+
fun := call.Fun
123+
switch f := fun.(type) {
124+
case *ast.IndexExpr:
125+
fun = f.X
126+
case *ast.IndexListExpr:
127+
fun = f.X
128+
}
129+
sel, ok := fun.(*ast.SelectorExpr)
130+
if !ok {
131+
return true
132+
}
133+
pkg, ok := sel.X.(*ast.Ident)
134+
if !ok || pkg.Name != "core" {
135+
return true
136+
}
137+
if sel.Sel.Name != "NewComponent" && sel.Sel.Name != "NewComponentWith" {
138+
return true
139+
}
140+
lit, ok := call.Args[0].(*ast.BasicLit)
141+
if !ok || lit.Kind != token.STRING {
142+
return true
143+
}
144+
name, err := strconv.Unquote(lit.Value)
145+
if err != nil || name == "" {
146+
return true
147+
}
148+
ident, ok := call.Args[1].(*ast.Ident)
149+
if !ok {
150+
return true
151+
}
152+
if _, ok := vars[ident.Name]; !ok {
153+
return true
154+
}
155+
names = append(names, name)
156+
return true
157+
})
158+
return names
159+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package server
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
)
7+
8+
func TestComponentNamesForTemplate(t *testing.T) {
9+
tmpl := filepath.Join("..", "..", "..", "docs", "examples", "components", "templates", "input_component.rtml")
10+
names := componentNamesForTemplate(tmpl)
11+
t.Logf("names: %v", names)
12+
if len(names) == 0 {
13+
t.Fatalf("expected component names for %s", tmpl)
14+
}
15+
found := false
16+
for _, name := range names {
17+
if name == "InputComponent" {
18+
found = true
19+
break
20+
}
21+
}
22+
if !found {
23+
t.Fatalf("expected InputComponent in %v", names)
24+
}
25+
}
26+
27+
func TestComponentNamesForTemplateGenerics(t *testing.T) {
28+
tmpl := filepath.Join("..", "..", "..", "docs", "examples", "components", "templates", "webgl_component.rtml")
29+
names := componentNamesForTemplate(tmpl)
30+
t.Logf("names: %v", names)
31+
found := false
32+
for _, name := range names {
33+
if name == "WebGLComponent" {
34+
found = true
35+
break
36+
}
37+
}
38+
if !found {
39+
t.Fatalf("expected WebGLComponent in %v", names)
40+
}
41+
}
42+
43+
func TestComponentNamesForTemplateNoMatch(t *testing.T) {
44+
tmpl := filepath.Join("hmr_template_test.go")
45+
if names := componentNamesForTemplate(tmpl); names != nil {
46+
t.Fatalf("expected nil, got %v", names)
47+
}
48+
}

0 commit comments

Comments
 (0)