server
packageAPI reference for the server
package.
Imports
(28)os
STD
path/filepath
STD
testing
STD
time
STD
os/exec
STD
runtime
PKG
github.com/mirkobrombin/go-signal/v2/pkg/bus
STD
encoding/json
STD
fmt
STD
net/http
INT
github.com/rfwlab/rfw/v2/cmd/rfw/utils
STD
go/ast
STD
go/parser
STD
go/token
STD
strconv
STD
strings
STD
bufio
STD
context
STD
expvar
STD
net/http/httputil
STD
net/url
STD
os/signal
STD
sync
STD
syscall
PKG
github.com/fsnotify/fsnotify
INT
github.com/rfwlab/rfw/v2/cmd/rfw/build
INT
github.com/rfwlab/rfw/v2/cmd/rfw/plugins
INT
github.com/rfwlab/rfw/v2/host
TestIncrementPort
TestIncrementPort verifies port arithmetic.
Parameters
func TestIncrementPort(t *testing.T)
{
if got := incrementPort("8080"); got != "8081" {
t.Fatalf("expected 8081, got %s", got)
}
}
TestReadBuildType
TestReadBuildType reads build type from temporary manifest.
Parameters
func TestReadBuildType(t *testing.T)
{
dir := t.TempDir()
t.Chdir(dir)
// No file should return empty string.
if got := readBuildType(); got != "" {
t.Fatalf("expected empty build type, got %q", got)
}
// Create manifest and verify value.
data := []byte(`{"build":{"type":"SSC"}}`)
if err := os.WriteFile(filepath.Join(dir, "rfw.json"), data, 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
if got := readBuildType(); got != "ssc" {
t.Fatalf("expected 'ssc', got %q", got)
}
}
TestIsGenerated
TestIsGenerated ensures generated files are skipped.
Parameters
func TestIsGenerated(t *testing.T)
{
tests := map[string]bool{
"rfw_devtools.go": true,
"some/rfw_generated.go": true,
"rfw.go": false,
"cmd/rfw/server.go": false,
}
for path, want := range tests {
if got := isGenerated(path); got != want {
t.Fatalf("isGenerated(%q) = %v, want %v", path, got, want)
}
}
}
TestShouldIgnore
TestShouldIgnore verifies the ignore window logic.
Parameters
func TestShouldIgnore(t *testing.T)
{
s := &Server{}
if s.shouldIgnore(time.Now()) {
t.Fatalf("expected no ignore by default")
}
s.ignoreUntil = time.Now().Add(time.Second)
if !s.shouldIgnore(time.Now()) {
t.Fatalf("expected ignore within window")
}
}
TestDebouncerCoalescesBursts
A burst of triggers within the window must coalesce into a single fire.
Parameters
func TestDebouncerCoalescesBursts(t *testing.T)
{
var d debouncer
for i := 0; i < 5; i++ {
d.trigger(30 * time.Millisecond)
time.Sleep(5 * time.Millisecond)
}
select {
case <-d.C:
d.fired()
case <-time.After(time.Second):
t.Fatalf("debouncer never fired")
}
if d.C != nil {
t.Fatalf("channel not cleared after fire")
}
// No second fire is pending.
d.trigger(20 * time.Millisecond)
select {
case <-d.C:
d.fired()
case <-time.After(time.Second):
t.Fatalf("debouncer did not re-arm")
}
}
TestStopHostGraceful
stopHost must terminate the child gracefully: a process that honours
SIGTERM exits without being killed, well before the kill timeout.
Parameters
func TestStopHostGraceful(t *testing.T)
{
if runtime.GOOS == "windows" {
t.Skip("SIGTERM not supported on windows")
}
s := NewServer("0", false)
s.hostCmd = exec.Command("sh", "-c", `trap 'exit 0' TERM; sleep 30 & wait`)
if err := s.hostCmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
start := time.Now()
s.stopHost()
if elapsed := time.Since(start); elapsed >= hostStopTimeout {
t.Fatalf("stopHost fell back to kill after %s", elapsed)
}
if s.hostCmd != nil {
t.Fatalf("hostCmd not cleared")
}
}
RebuildEvent
RebuildEvent is emitted when a file change triggers a rebuild.
type RebuildEvent struct
Fields
| Name | Type | Description |
|---|---|---|
| Path | string | |
| Success | bool | |
| Error | string |
devMessage
type devMessage struct
Fields
| Name | Type | Description |
|---|---|---|
| Type | string | json:"type" |
| Path | string | json:"path,omitempty" |
| Component | string | json:"component,omitempty" |
| Markup | string | json:"markup,omitempty" |
componentNamesForTemplate
Parameters
Returns
func componentNamesForTemplate(templatePath string) []string
{
abs := templatePath
if v, err := filepath.Abs(templatePath); err == nil {
abs = v
}
dir := filepath.Dir(abs)
if filepath.Base(dir) == "templates" {
dir = filepath.Dir(dir)
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
rel, err := filepath.Rel(dir, abs)
if err != nil {
rel = filepath.Base(abs)
}
rel = filepath.ToSlash(rel)
var names []string
seen := map[string]struct{}{}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
continue
}
goFile := filepath.Join(dir, entry.Name())
fileNames := embedVariablesForTemplate(goFile, rel)
if len(fileNames) == 0 {
continue
}
comps := componentsUsingTemplates(goFile, fileNames)
for _, name := range comps {
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
}
if len(names) == 0 {
return nil
}
return names
}
embedVariablesForTemplate
Parameters
Returns
func embedVariablesForTemplate(goFile, rel string) map[string]struct{}
{
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, goFile, nil, parser.ParseComments)
if err != nil {
return nil
}
vars := map[string]struct{}{}
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.VAR {
continue
}
for _, spec := range gen.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
if !specEmbedsPath(rel, gen.Doc, vs.Doc, vs.Comment) {
continue
}
for _, name := range vs.Names {
vars[name.Name] = struct{}{}
}
}
}
return vars
}
specEmbedsPath
Parameters
Returns
func specEmbedsPath(rel string, groups ...*ast.CommentGroup) bool
{
for _, grp := range groups {
if grp == nil {
continue
}
for _, comment := range grp.List {
text := strings.TrimSpace(comment.Text)
if !strings.HasPrefix(text, "//go:embed") {
continue
}
fields := strings.Fields(strings.TrimPrefix(text, "//go:embed"))
for _, field := range fields {
candidate := strings.Trim(field, "`\"")
if candidate == "" {
continue
}
if filepath.ToSlash(candidate) == rel {
return true
}
}
}
}
return false
}
componentsUsingTemplates
Parameters
Returns
func componentsUsingTemplates(goFile string, vars map[string]struct{}) []string
{
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, goFile, nil, 0)
if err != nil {
return nil
}
var names []string
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) < 2 {
return true
}
fun := call.Fun
switch f := fun.(type) {
case *ast.IndexExpr:
fun = f.X
case *ast.IndexListExpr:
fun = f.X
}
sel, ok := fun.(*ast.SelectorExpr)
if !ok {
return true
}
pkg, ok := sel.X.(*ast.Ident)
if !ok || pkg.Name != "core" {
return true
}
if sel.Sel.Name != "NewComponent" && sel.Sel.Name != "NewComponentWith" {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
name, err := strconv.Unquote(lit.Value)
if err != nil || name == "" {
return true
}
ident, ok := call.Args[1].(*ast.Ident)
if !ok {
return true
}
if _, ok := vars[ident.Name]; !ok {
return true
}
names = append(names, name)
return true
})
return names
}
TestComponentNamesForTemplate
Parameters
func TestComponentNamesForTemplate(t *testing.T)
{
tmpl := filepath.Join("testdata", "components", "templates", "input_component.rtml")
names := componentNamesForTemplate(tmpl)
t.Logf("names: %v", names)
if len(names) == 0 {
t.Fatalf("expected component names for %s", tmpl)
}
found := false
for _, name := range names {
if name == "InputComponent" {
found = true
break
}
}
if !found {
t.Fatalf("expected InputComponent in %v", names)
}
}
TestComponentNamesForTemplateGenerics
Parameters
func TestComponentNamesForTemplateGenerics(t *testing.T)
{
tmpl := filepath.Join("testdata", "components", "templates", "webgl_component.rtml")
names := componentNamesForTemplate(tmpl)
t.Logf("names: %v", names)
found := false
for _, name := range names {
if name == "WebGLComponent" {
found = true
break
}
}
if !found {
t.Fatalf("expected WebGLComponent in %v", names)
}
}
TestComponentNamesForTemplateNoMatch
Parameters
func TestComponentNamesForTemplateNoMatch(t *testing.T)
{
tmpl := filepath.Join("hmr_template_test.go")
if names := componentNamesForTemplate(tmpl); names != nil {
t.Fatalf("expected nil, got %v", names)
}
}
Server
Server builds, serves, and watches an RFW project during development.
type Server struct
Methods
Parameters
func (*Server) handleHMR(w http.ResponseWriter, r *http.Request)
{
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
client := make(chan []byte, 8)
s.hmrMu.Lock()
s.hmrClients[client] = struct{}{}
s.hmrMu.Unlock()
utils.Debug("hmr client connected")
if _, err := fmt.Fprint(w, ": connected\n\n"); err != nil {
return
}
flusher.Flush()
defer func() {
s.hmrMu.Lock()
delete(s.hmrClients, client)
s.hmrMu.Unlock()
utils.Debug("hmr client disconnected")
}()
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
for {
select {
case msg, ok := <-client:
if !ok {
return
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil {
return
}
flusher.Flush()
case <-ping.C:
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
Parameters
Returns
func (*Server) broadcast(msg devMessage) error
{
data, err := json.Marshal(msg)
if err != nil {
return err
}
s.hmrMu.Lock()
defer s.hmrMu.Unlock()
if len(s.hmrClients) == 0 {
return nil
}
for ch := range s.hmrClients {
select {
case ch <- data:
default:
// Drop slow clients to avoid blocking rebuilds.
delete(s.hmrClients, ch)
close(ch)
}
}
return nil
}
Parameters
Returns
func (*Server) broadcastReload(path string) error
{
rel := path
if abs, err := filepath.Abs(path); err == nil {
if cwd, err := filepath.Abs("."); err == nil {
if r, err := filepath.Rel(cwd, abs); err == nil {
rel = filepath.ToSlash(r)
}
}
}
utils.Debug(fmt.Sprintf("broadcasting reload for %s", rel))
return s.broadcast(devMessage{Type: "reload", Path: rel})
}
Parameters
Returns
func (*Server) broadcastTemplateUpdate(path, component, markup string) error
{
rel := path
if abs, err := filepath.Abs(path); err == nil {
if cwd, err := filepath.Abs("."); err == nil {
if r, err := filepath.Rel(cwd, abs); err == nil {
rel = filepath.ToSlash(r)
}
}
}
utils.Debug(fmt.Sprintf("streaming template update for %s (%s)", rel, component))
return s.broadcast(devMessage{Type: "rtml", Path: rel, Component: component, Markup: markup})
}
Start builds the project and starts its development servers and watcher.
Returns
func (*Server) Start() error
{
if err := build.Build(); err != nil {
return err
}
// Detect build type from manifest to know if host components are enabled.
s.buildType = readBuildType()
var mux *http.ServeMux
httpsPort := incrementPort(s.Port)
if s.buildType == "ssc" {
s.hostPort = incrementPort(httpsPort)
if err := s.startHost(); err != nil {
return err
}
target := &url.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%s", s.hostPort)}
proxy := httputil.NewSingleHostReverseProxy(target)
mux = http.NewServeMux()
mux.HandleFunc("/__rfw/hmr", s.handleHMR)
mux.Handle("/", proxy)
} else {
root := filepath.Join("build", "client")
mux = hostpkg.NewMux(root)
mux.HandleFunc("/__rfw/hmr", s.handleHMR)
}
server := &http.Server{
Addr: ":" + s.Port,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
if err := server.ListenAndServe(); err != nil {
utils.Fatal("Server failed: ", err)
}
}()
go func() {
if err := hostpkg.ListenAndServeTLSWithMux(":"+httpsPort, mux); err != nil {
utils.Fatal("HTTPS server failed: ", err)
}
}()
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
s.watcher = watcher
if err := s.addWatchers("."); err != nil {
return err
}
go s.watchFiles()
signal.Notify(s.stopCh, syscall.SIGINT, syscall.SIGTERM)
localIP := ""
if s.Host {
localIP, err = utils.GetLocalIP()
if err != nil {
return err
}
}
utils.ClearScreen()
utils.PrintStartupInfo(s.Port, httpsPort, localIP, s.Host)
go s.listenForCommands()
<-s.stopCh
utils.Info("Server stopped.")
s.stopHost()
return nil
}
func (*Server) listenForCommands()
{
reader := bufio.NewReader(os.Stdin)
for {
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
switch strings.ToLower(input) {
case "h":
utils.PrintHelp()
case "u":
utils.ClearScreen()
localIP, err := utils.GetLocalIP()
if err != nil {
utils.Fatal("Failed to get local IP address: ", err)
}
httpsPort := incrementPort(s.Port)
utils.PrintStartupInfo(s.Port, httpsPort, localIP, s.Host)
case "c", "q":
utils.Info("Closing the server...")
s.stopCh <- syscall.SIGINT
return
case "o":
utils.Info("Opening the browser...")
url := fmt.Sprintf("http://localhost:%s/", s.Port)
if err := utils.OpenBrowser(url); err != nil {
utils.Info(fmt.Sprintf("Failed to open browser: %v", err))
}
default:
utils.Info("Unknown command. Press 'h' for help.")
}
}
}
Parameters
Returns
func (*Server) addWatchers(root string) error
{
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
name := d.Name()
if name != "." && (name == "build" || strings.HasPrefix(name, ".")) {
return filepath.SkipDir
}
return s.watcher.Add(path)
}
return nil
})
}
Parameters
Returns
func (*Server) shouldIgnore(now time.Time) bool
{
return now.Before(s.ignoreUntil)
}
func (*Server) watchFiles()
{
var (
deb debouncer
pendingPath string
pendingSwap bool
)
for {
select {
case event, ok := <-s.watcher.Events:
if !ok {
return
}
if s.shouldIgnore(time.Now()) {
continue
}
utils.Debug(fmt.Sprintf("event: %s", event))
if event.Op&fsnotify.Create != 0 {
if fi, err := os.Stat(event.Name); err == nil && fi.IsDir() {
name := filepath.Base(event.Name)
if name == "build" || strings.HasPrefix(name, ".") {
continue
}
utils.Debug(fmt.Sprintf("watching new directory: %s", event.Name))
_ = s.watcher.Add(event.Name)
continue
}
}
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 {
if isGenerated(event.Name) {
continue
}
if strings.HasSuffix(event.Name, ".go") ||
strings.HasSuffix(event.Name, ".rtml") ||
strings.HasSuffix(event.Name, ".md") ||
plugins.NeedsRebuild(event.Name) {
// Template edits stream to the browser immediately: the
// hot swap does not depend on the wasm rebuild, which
// then runs to keep dist consistent for full reloads.
if strings.HasSuffix(event.Name, ".rtml") {
if markup, err := os.ReadFile(event.Name); err == nil {
if comps := componentNamesForTemplate(event.Name); len(comps) > 0 {
pendingSwap = true
for _, name := range comps {
if err := s.broadcastTemplateUpdate(event.Name, name, string(markup)); err != nil {
utils.Debug(fmt.Sprintf("template broadcast failed: %v", err))
}
}
}
} else {
utils.Debug(fmt.Sprintf("failed reading template %s: %v", event.Name, err))
}
}
// Coalesce bursts of events into a single rebuild.
pendingPath = event.Name
deb.trigger(rebuildDebounce)
}
}
case <-deb.C:
deb.fired()
s.rebuild(pendingPath, pendingSwap)
pendingSwap = false
case err, ok := <-s.watcher.Errors:
if !ok {
return
}
utils.Info(fmt.Sprintf("Watcher error: %v", err))
case <-s.stopCh:
if err := s.watcher.Close(); err != nil {
utils.Debug(fmt.Sprintf("watcher close failed: %v", err))
}
s.stopHost()
return
}
}
}
rebuild runs a full project build for a change at path, reporting timing and streaming the result to connected browsers.
Parameters
func (*Server) rebuild(path string, templateSwapped bool)
{
rebuilds.Add(1)
utils.Info("Changes detected, rebuilding...")
start := time.Now()
if err := build.Build(); err != nil {
if emitErr := signalbus.Emit(context.Background(), RebuildBus, RebuildEvent{Path: path, Success: false, Error: err.Error()}); emitErr != nil {
utils.Debug(fmt.Sprintf("rebuild event emit failed: %v", emitErr))
}
// A compile error must not kill the dev server: keep
// watching so the next save can succeed.
utils.Error("Failed to rebuild project: ", err)
return
}
if emitErr := signalbus.Emit(context.Background(), RebuildBus, RebuildEvent{Path: path, Success: true}); emitErr != nil {
utils.Debug(fmt.Sprintf("rebuild event emit failed: %v", emitErr))
}
utils.Info(fmt.Sprintf("Rebuilt in %s", time.Since(start).Round(time.Millisecond)))
if !templateSwapped {
if err := s.broadcastReload(path); err != nil {
utils.Debug(fmt.Sprintf("hmr broadcast skipped: %v", err))
}
}
if s.buildType == "ssc" {
s.stopHost()
if err := s.startHost(); err != nil {
utils.Fatal("Failed to restart host server: ", err)
}
}
s.ignoreUntil = time.Now().Add(ignoreDelay)
drainWatcher(s.watcher)
}
Returns
func (*Server) startHost() error
{
path := filepath.Join("build", "host", "host")
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
info, err := os.Lstat(absPath)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("host binary is not a regular file: %s", absPath)
}
s.hostCmd = exec.Command("./build/host/host")
s.hostCmd.Stdout = os.Stdout
s.hostCmd.Stderr = os.Stderr
if s.hostPort != "" {
env := os.Environ()
env = append(env, fmt.Sprintf("RFW_HOST_PORT=%s", s.hostPort))
s.hostCmd.Env = env
}
return s.hostCmd.Start()
}
stopHost asks the host process to exit with SIGTERM so it can close its sockets cleanly, then kills it if it has not exited within hostStopTimeout.
func (*Server) stopHost()
{
if s.hostCmd == nil || s.hostCmd.Process == nil {
s.hostCmd = nil
return
}
proc := s.hostCmd.Process
if err := proc.Signal(syscall.SIGTERM); err == nil {
done := make(chan struct{})
go func() {
_, _ = proc.Wait()
close(done)
}()
select {
case <-done:
s.hostCmd = nil
return
case <-time.After(hostStopTimeout):
utils.Debug("host did not exit after SIGTERM, killing")
}
}
_ = proc.Kill()
_, _ = proc.Wait()
s.hostCmd = nil
}
Fields
| Name | Type | Description |
|---|---|---|
| Port | string | |
| Host | bool | |
| stopCh | chan os.Signal | |
| watcher | *fsnotify.Watcher | |
| hostCmd | *exec.Cmd | |
| buildType | string | |
| hostPort | string | |
| ignoreUntil | time.Time | |
| hmrMu | sync.Mutex | |
| hmrClients | map[chan []byte]struct{} |
debouncer
debouncer coalesces bursts of triggers into a single fire on C after the
configured delay; every trigger during the window restarts the countdown.
type debouncer struct
Methods
Parameters
func (*debouncer) trigger(delay time.Duration)
{
if d.timer == nil {
d.timer = time.NewTimer(delay)
d.C = d.timer.C
return
}
if !d.timer.Stop() {
select {
case <-d.timer.C:
default:
}
}
d.timer.Reset(delay)
}
func (*debouncer) fired()
{
d.timer = nil
d.C = nil
}
Fields
| Name | Type | Description |
|---|---|---|
| timer | *time.Timer | |
| C | <-chan time.Time |
NewServer
NewServer creates a development server on port.
Parameters
Returns
func NewServer(port string, host bool) *Server
{
return &Server{
Port: port,
Host: host,
stopCh: make(chan os.Signal, 1),
hmrClients: make(map[chan []byte]struct{}),
}
}
isGenerated
Parameters
Returns
func isGenerated(path string) bool
{
return strings.HasPrefix(filepath.Base(path), "rfw_")
}
drainWatcher
Parameters
func drainWatcher(w *fsnotify.Watcher)
{
for {
select {
case <-w.Events:
case <-w.Errors:
case <-time.After(50 * time.Millisecond):
return
}
}
}
readBuildType
readBuildType reads the build type from rfw.json if present.
Returns
func readBuildType() string
{
var manifest struct {
Build struct {
Type string `json:"type"`
} `json:"build"`
}
data, err := os.ReadFile("rfw.json")
if err != nil {
return ""
}
_ = json.Unmarshal(data, &manifest)
return strings.ToLower(manifest.Build.Type)
}
incrementPort
Parameters
Returns
func incrementPort(port string) string
{
p, _ := strconv.Atoi(port)
return strconv.Itoa(p + 1)
}