mirror of
https://github.com/php/frankenphp.git
synced 2026-03-24 09:02:11 +01:00
This patch brings hot reloading capabilities to PHP apps: in development, the browser will automatically refresh the page when any source file changes! It's similar to HMR in JavaScript. It is built on top of [the watcher mechanism](https://frankenphp.dev/docs/config/#watching-for-file-changes) and of the [Mercure](https://frankenphp.dev/docs/mercure/) integration. Each time a watched file is modified, a Mercure update is sent, giving the ability to the client to reload the page, or part of the page (assets, images...). Here is an example implementation: ```caddyfile root ./public mercure { subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} anonymous } php_server { hot_reload } ``` ```php <?php header('Content-Type: text/html'); ?> <!DOCTYPE html> <html lang="en"> <head> <title>Test</title> <script> const es = new EventSource('<?=$_SERVER['FRANKENPHP_HOT_RELOAD']?>'); es.onmessage = () => location.reload(); </script> </head> <body> Hello ``` I plan to create a helper JS library to handle more advanced cases (reloading CSS, JS, etc), similar to [HotWire Spark](https://github.com/hotwired/spark). Be sure to attend my SymfonyCon to learn more! There is still room for improvement: - Provide an option to only trigger the update without reloading the worker for some files (ex, images, JS, CSS...) - Support classic mode (currently, only the worker mode is supported) - Don't reload all workers when only the files used by one change However, this PR is working as-is and can be merged as a first step. This patch heavily refactors the watcher module. Maybe it will be possible to extract it as a standalone library at some point (would be useful to add a similar feature but not tight to PHP as a Caddy module). --------- Signed-off-by: Kévin Dunglas <kevin@dunglas.fr> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
222 lines
4.5 KiB
Go
222 lines
4.5 KiB
Go
//go:build !nowatcher
|
|
|
|
package watcher
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/e-dant/watcher/watcher-go"
|
|
)
|
|
|
|
const (
|
|
// duration to wait before triggering a reload after a file change
|
|
debounceDuration = 150 * time.Millisecond
|
|
// times to retry watching if the watcher was closed prematurely
|
|
maxFailureCount = 5
|
|
failureResetDuration = 5 * time.Second
|
|
)
|
|
|
|
var (
|
|
ErrAlreadyStarted = errors.New("watcher is already running")
|
|
|
|
failureMu sync.Mutex
|
|
watcherIsActive atomic.Bool
|
|
|
|
// the currently active file watcher
|
|
activeWatcher *globalWatcher
|
|
// after stopping the watcher we will wait for eventual reloads to finish
|
|
reloadWaitGroup sync.WaitGroup
|
|
// we are passing the context from the main package to the watcher
|
|
globalCtx context.Context
|
|
// we are passing the globalLogger from the main package to the watcher
|
|
globalLogger *slog.Logger
|
|
)
|
|
|
|
type PatternGroup struct {
|
|
Patterns []string
|
|
Callback func([]*watcher.Event)
|
|
}
|
|
|
|
type eventHolder struct {
|
|
patternGroup *PatternGroup
|
|
event *watcher.Event
|
|
}
|
|
|
|
type globalWatcher struct {
|
|
groups []*PatternGroup
|
|
watchers []*pattern
|
|
events chan eventHolder
|
|
stop chan struct{}
|
|
}
|
|
|
|
func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGroup) error {
|
|
if len(groups) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if watcherIsActive.Load() {
|
|
return ErrAlreadyStarted
|
|
}
|
|
|
|
watcherIsActive.Store(true)
|
|
globalCtx = ct
|
|
globalLogger = slogger
|
|
|
|
activeWatcher = &globalWatcher{groups: groups}
|
|
|
|
for _, g := range groups {
|
|
if len(g.Patterns) == 0 {
|
|
continue
|
|
}
|
|
|
|
for _, p := range g.Patterns {
|
|
activeWatcher.watchers = append(activeWatcher.watchers, &pattern{patternGroup: g, value: p})
|
|
}
|
|
}
|
|
|
|
if err := activeWatcher.startWatching(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func DrainWatcher() {
|
|
if !watcherIsActive.Load() {
|
|
return
|
|
}
|
|
|
|
watcherIsActive.Store(false)
|
|
|
|
if globalLogger.Enabled(globalCtx, slog.LevelDebug) {
|
|
globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "stopping watcher")
|
|
}
|
|
|
|
activeWatcher.stopWatching()
|
|
reloadWaitGroup.Wait()
|
|
activeWatcher = nil
|
|
}
|
|
|
|
// TODO: how to test this?
|
|
func (p *pattern) retryWatching() {
|
|
failureMu.Lock()
|
|
defer failureMu.Unlock()
|
|
|
|
if p.failureCount >= maxFailureCount {
|
|
if globalLogger.Enabled(globalCtx, slog.LevelWarn) {
|
|
globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "giving up watching", slog.String("pattern", p.value))
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if globalLogger.Enabled(globalCtx, slog.LevelInfo) {
|
|
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "watcher was closed prematurely, retrying...", slog.String("pattern", p.value))
|
|
}
|
|
|
|
p.failureCount++
|
|
|
|
p.startSession()
|
|
|
|
// reset the failure-count if the watcher hasn't reached max failures after 5 seconds
|
|
go func() {
|
|
time.Sleep(failureResetDuration)
|
|
|
|
failureMu.Lock()
|
|
if p.failureCount < maxFailureCount {
|
|
p.failureCount = 0
|
|
}
|
|
failureMu.Unlock()
|
|
}()
|
|
}
|
|
|
|
func (g *globalWatcher) startWatching() error {
|
|
g.events = make(chan eventHolder)
|
|
g.stop = make(chan struct{})
|
|
|
|
if err := g.parseFilePatterns(); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, w := range g.watchers {
|
|
w.events = g.events
|
|
w.startSession()
|
|
}
|
|
|
|
go g.listenForFileEvents()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *globalWatcher) parseFilePatterns() error {
|
|
for _, w := range g.watchers {
|
|
if err := w.parse(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *globalWatcher) stopWatching() {
|
|
close(g.stop)
|
|
for _, w := range g.watchers {
|
|
w.stop()
|
|
}
|
|
}
|
|
|
|
func (g *globalWatcher) listenForFileEvents() {
|
|
timer := time.NewTimer(debounceDuration)
|
|
timer.Stop()
|
|
|
|
eventsPerGroup := make(map[*PatternGroup][]*watcher.Event, len(g.groups))
|
|
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-g.stop:
|
|
return
|
|
case eh := <-g.events:
|
|
timer.Reset(debounceDuration)
|
|
|
|
eventsPerGroup[eh.patternGroup] = append(eventsPerGroup[eh.patternGroup], eh.event)
|
|
case <-timer.C:
|
|
timer.Stop()
|
|
|
|
if globalLogger.Enabled(globalCtx, slog.LevelInfo) {
|
|
var events []*watcher.Event
|
|
for _, eventList := range eventsPerGroup {
|
|
events = append(events, eventList...)
|
|
}
|
|
|
|
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "filesystem changes detected", slog.Any("events", events))
|
|
}
|
|
|
|
g.scheduleReload(eventsPerGroup)
|
|
eventsPerGroup = make(map[*PatternGroup][]*watcher.Event, len(g.groups))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (g *globalWatcher) scheduleReload(eventsPerGroup map[*PatternGroup][]*watcher.Event) {
|
|
reloadWaitGroup.Add(1)
|
|
|
|
// Call callbacks in order
|
|
for _, g := range g.groups {
|
|
if len(g.Patterns) == 0 {
|
|
g.Callback(nil)
|
|
}
|
|
|
|
if e, ok := eventsPerGroup[g]; ok {
|
|
g.Callback(e)
|
|
}
|
|
}
|
|
|
|
reloadWaitGroup.Done()
|
|
}
|