Skip to content

Commit c226f2b

Browse files
committed
chore: add debounce file watcher
1 parent 5755b8a commit c226f2b

File tree

2 files changed

+206
-21
lines changed

2 files changed

+206
-21
lines changed

internal/functions/serve/serve.go

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package serve
22

33
import (
4-
"bufio"
54
"context"
65
_ "embed"
76
"encoding/json"
@@ -48,6 +47,7 @@ func (mode InspectMode) toFlag() string {
4847
type RuntimeOption struct {
4948
InspectMode *InspectMode
5049
InspectMain bool
50+
fileWatcher *DebounceFileWatcher
5151
}
5252

5353
func (i *RuntimeOption) toArgs() []string {
@@ -70,13 +70,19 @@ const (
7070
var mainFuncEmbed string
7171

7272
func Run(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, runtimeOption RuntimeOption, fsys afero.Fs) error {
73-
if err := restartEdgeRuntime(ctx, envFilePath, noVerifyJWT, importMapPath, runtimeOption, fsys); err != nil {
73+
watcher, err := NewDebounceFileWatcher()
74+
if err != nil {
7475
return err
7576
}
76-
watcher := NewFileWatcher()
7777
go watcher.Start(ctx)
78+
defer watcher.Close()
79+
runtimeOption.fileWatcher = watcher
80+
if err := restartEdgeRuntime(ctx, envFilePath, noVerifyJWT, importMapPath, runtimeOption, fsys); err != nil {
81+
return err
82+
}
7883
streamer := NewLogStreamer()
7984
go streamer.Start(ctx)
85+
defer streamer.Close()
8086
for {
8187
select {
8288
case <-ctx.Done():
@@ -122,7 +128,7 @@ func NewLogStreamer() logStreamer {
122128
}
123129
}
124130

125-
func (s logStreamer) Start(ctx context.Context) {
131+
func (s *logStreamer) Start(ctx context.Context) {
126132
for {
127133
if err := utils.DockerStreamLogs(ctx, utils.EdgeRuntimeId, os.Stdout, os.Stderr, func(lo *container.LogsOptions) {
128134
lo.Timestamps = true
@@ -136,23 +142,8 @@ func (s logStreamer) Start(ctx context.Context) {
136142
}
137143
}
138144

139-
type fileWatcher struct {
140-
RestartCh chan struct{}
141-
}
142-
143-
func NewFileWatcher() fileWatcher {
144-
return fileWatcher{
145-
RestartCh: make(chan struct{}, 1),
146-
}
147-
}
148-
149-
func (w *fileWatcher) Start(ctx context.Context) {
150-
// TODO: implement fs.notify
151-
fmt.Fprintln(os.Stderr, "Press enter to reload...")
152-
scanner := bufio.NewScanner(os.Stdin)
153-
for scanner.Scan() {
154-
w.RestartCh <- struct{}{}
155-
}
145+
func (s *logStreamer) Close() {
146+
close(s.ErrCh)
156147
}
157148

158149
func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool, importMapPath string, dbUrl string, runtimeOption RuntimeOption, fsys afero.Fs) error {
@@ -192,6 +183,20 @@ func ServeFunctions(ctx context.Context, envFilePath string, noVerifyJWT *bool,
192183
if err != nil {
193184
return err
194185
}
186+
if watcher := runtimeOption.fileWatcher; watcher != nil {
187+
var watchPaths []string
188+
for _, b := range binds {
189+
// Get the directory containing the path
190+
hostPath := strings.Split(b, ":")[0]
191+
// Ignore container names
192+
if strings.ContainsRune(hostPath, rune(filepath.Separator)) {
193+
watchPaths = append(watchPaths, hostPath)
194+
}
195+
}
196+
if err := watcher.SetWatchPaths(watchPaths, fsys); err != nil {
197+
return err
198+
}
199+
}
195200
env = append(env, "SUPABASE_INTERNAL_FUNCTIONS_CONFIG="+functionsConfigString)
196201
// 3. Parse entrypoint script
197202
cmd := append([]string{

internal/functions/serve/watcher.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package serve
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io/fs"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"time"
11+
12+
"github.com/fsnotify/fsnotify"
13+
"github.com/go-errors/errors"
14+
"github.com/spf13/afero"
15+
"github.com/supabase/cli/internal/utils"
16+
)
17+
18+
const (
19+
// Debounce duration for file changes
20+
debounceDuration = 500 * time.Millisecond
21+
restartEvents = fsnotify.Write | fsnotify.Create | fsnotify.Remove | fsnotify.Rename
22+
)
23+
24+
var (
25+
// Directories to ignore.
26+
ignoredDirNames = []string{
27+
".git",
28+
"node_modules",
29+
".vscode",
30+
".idea",
31+
".DS_Store",
32+
"vendor",
33+
}
34+
35+
// Patterns for ignoring file events.
36+
ignoredFilePatterns = []struct {
37+
Prefix string // File basename prefix
38+
Suffix string // File basename suffix
39+
ExactMatch string // File basename exact match
40+
Op fsnotify.Op // Specific operation to ignore for this pattern (0 for any op)
41+
}{
42+
{Suffix: "~"}, // Common backup files (e.g., emacs, gedit)
43+
{Prefix: ".", Suffix: ".swp"}, // Vim swap files
44+
{Prefix: ".", Suffix: ".swx"}, // Vim swap files (extended)
45+
{Prefix: "___", Suffix: "___"}, // Deno deploy/bundle temporary files often look like ___<slug>___<hash>___
46+
{Prefix: "___"}, // Some other editor temp files might start with this
47+
{Suffix: ".tmp"}, // Generic temp files
48+
{Prefix: ".#"}, // Emacs lock files
49+
{Suffix: "___", Op: fsnotify.Chmod}, // Deno specific temp file pattern during write (often involves a chmod)
50+
}
51+
)
52+
53+
// isIgnoredFileEvent checks if a file event should be ignored based on predefined patterns.
54+
func isIgnoredFileEvent(eventName string, eventOp fsnotify.Op) bool {
55+
baseName := filepath.Base(eventName)
56+
for _, p := range ignoredFilePatterns {
57+
match := false
58+
if p.ExactMatch != "" && baseName == p.ExactMatch {
59+
match = true
60+
} else {
61+
// Check prefix if specified
62+
prefixMatch := p.Prefix == "" || strings.HasPrefix(baseName, p.Prefix)
63+
// Check suffix if specified
64+
suffixMatch := p.Suffix == "" || strings.HasSuffix(baseName, p.Suffix)
65+
66+
// Both prefix and suffix must match
67+
if p.Prefix != "" && p.Suffix != "" {
68+
match = prefixMatch && suffixMatch
69+
// Only prefix specified
70+
} else if p.Prefix != "" {
71+
match = prefixMatch
72+
// Only suffix specified
73+
} else if p.Suffix != "" {
74+
match = suffixMatch
75+
}
76+
}
77+
78+
if match {
79+
// If Op is 0, it means the pattern applies to any operation.
80+
// Otherwise, check if the event's operation is relevant to the pattern's Op.
81+
if p.Op == 0 || (eventOp&p.Op) != 0 {
82+
return true
83+
}
84+
}
85+
}
86+
return false
87+
}
88+
89+
type DebounceFileWatcher struct {
90+
watcher *fsnotify.Watcher
91+
restartTimer *time.Timer
92+
RestartCh <-chan time.Time
93+
ErrCh <-chan error
94+
}
95+
96+
func NewDebounceFileWatcher() (*DebounceFileWatcher, error) {
97+
restartTimer := time.NewTimer(debounceDuration)
98+
if !restartTimer.Stop() {
99+
return nil, errors.New("failed to initialise timer")
100+
}
101+
watcher, err := fsnotify.NewWatcher()
102+
if err != nil {
103+
return nil, errors.Errorf("failed to create file watcher: %w", err)
104+
}
105+
return &DebounceFileWatcher{
106+
watcher: watcher,
107+
ErrCh: watcher.Errors,
108+
restartTimer: restartTimer,
109+
RestartCh: restartTimer.C,
110+
}, nil
111+
}
112+
113+
func (w *DebounceFileWatcher) Start(ctx context.Context) {
114+
for {
115+
event, ok := <-w.watcher.Events
116+
if !ok {
117+
return
118+
}
119+
120+
if !event.Has(restartEvents) || isIgnoredFileEvent(event.Name, event.Op) {
121+
fmt.Fprintf(utils.GetDebugLogger(), "Ignoring file event: %s (%s)\n", event.Name, event.Op.String())
122+
continue
123+
}
124+
125+
fileName := filepath.Base(event.Name)
126+
if fileName == "config.toml" {
127+
fmt.Fprintf(os.Stderr, "Config file change detected: %s (%s) - will reload configuration\n", event.Name, event.Op.String())
128+
} else {
129+
fmt.Fprintf(os.Stderr, "File change detected: %s (%s)\n", event.Name, event.Op.String())
130+
}
131+
132+
if !w.restartTimer.Reset(debounceDuration) {
133+
fmt.Fprintln(utils.GetDebugLogger(), "Failed to restart debounce timer.")
134+
}
135+
}
136+
}
137+
138+
func (w *DebounceFileWatcher) SetWatchPaths(watchPaths []string, fsys afero.Fs) error {
139+
shouldWatchDirs := make(map[string]struct{})
140+
for _, hostPath := range watchPaths {
141+
// Ignore non-existent paths
142+
if err := afero.Walk(fsys, hostPath, func(path string, info fs.FileInfo, err error) error {
143+
if err != nil {
144+
return errors.New(err)
145+
}
146+
if path == hostPath || info.IsDir() {
147+
shouldWatchDirs[path] = struct{}{}
148+
}
149+
return nil
150+
}); err != nil {
151+
return err
152+
}
153+
}
154+
for hostPath := range shouldWatchDirs {
155+
if err := w.watcher.Add(hostPath); err != nil {
156+
return errors.Errorf("failed to watch directory: %w", err)
157+
}
158+
fmt.Fprintln(utils.GetDebugLogger(), "Added directory from watcher:", hostPath)
159+
}
160+
// Remove directories that are no longer needed
161+
for _, hostPath := range w.watcher.WatchList() {
162+
if _, ok := shouldWatchDirs[hostPath]; !ok {
163+
if err := w.watcher.Remove(hostPath); err != nil {
164+
return errors.Errorf("failed to remove watch directory: %w", err)
165+
}
166+
fmt.Fprintln(utils.GetDebugLogger(), "Removed directory from watcher:", hostPath)
167+
}
168+
}
169+
return nil
170+
}
171+
172+
func (r *DebounceFileWatcher) Close() error {
173+
if r.watcher != nil {
174+
return r.watcher.Close()
175+
}
176+
if r.restartTimer != nil {
177+
r.restartTimer.Stop()
178+
}
179+
return nil
180+
}

0 commit comments

Comments
 (0)