Skip to content

Commit 225ca40

Browse files
dunglasCopilot
andauthored
feat: hot reload (#2031)
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>
1 parent d200762 commit 225ca40

40 files changed

Lines changed: 1246 additions & 670 deletions

caddy/admin_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import (
44
"bytes"
55
"encoding/json"
66
"fmt"
7-
"github.com/dunglas/frankenphp/internal/fastabs"
87
"io"
98
"net/http"
109
"sync"
1110
"testing"
1211

12+
"github.com/dunglas/frankenphp/internal/fastabs"
13+
1314
"github.com/caddyserver/caddy/v2/caddytest"
1415
"github.com/dunglas/frankenphp"
1516
"github.com/stretchr/testify/assert"

caddy/app.go

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@ type FrankenPHPApp struct {
4949
NumThreads int `json:"num_threads,omitempty"`
5050
// MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads
5151
MaxThreads int `json:"max_threads,omitempty"`
52-
// Workers configures the worker scripts to start.
52+
// Workers configures the worker scripts to start
5353
Workers []workerConfig `json:"workers,omitempty"`
5454
// Overwrites the default php ini configuration
5555
PhpIni map[string]string `json:"php_ini,omitempty"`
5656
// The maximum amount of time a request may be stalled waiting for a thread
5757
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"`
5858

59+
opts []frankenphp.Option
5960
metrics frankenphp.Metrics
6061
ctx context.Context
6162
logger *slog.Logger
@@ -76,6 +77,9 @@ func (f *FrankenPHPApp) Provision(ctx caddy.Context) error {
7677
f.ctx = ctx
7778
f.logger = ctx.Slogger()
7879

80+
// We have at least 7 hardcoded options
81+
f.opts = make([]frankenphp.Option, 0, 7+len(options))
82+
7983
if httpApp, err := ctx.AppIfConfigured("http"); err == nil {
8084
if httpApp.(*caddyhttp.App).Metrics != nil {
8185
f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry())
@@ -135,11 +139,10 @@ func (f *FrankenPHPApp) Start() error {
135139
repl := caddy.NewReplacer()
136140

137141
optionsMU.RLock()
138-
opts := make([]frankenphp.Option, 0, len(options)+len(f.Workers)+7)
139-
opts = append(opts, options...)
142+
f.opts = append(f.opts, options...)
140143
optionsMU.RUnlock()
141144

142-
opts = append(opts,
145+
f.opts = append(f.opts,
143146
frankenphp.WithContext(f.ctx),
144147
frankenphp.WithLogger(f.logger),
145148
frankenphp.WithNumThreads(f.NumThreads),
@@ -150,31 +153,19 @@ func (f *FrankenPHPApp) Start() error {
150153
)
151154

152155
for _, w := range f.Workers {
153-
workerOpts := make([]frankenphp.WorkerOption, 0, len(w.requestOptions)+4)
154-
155-
if w.requestOptions == nil {
156-
workerOpts = append(workerOpts,
157-
frankenphp.WithWorkerEnv(w.Env),
158-
frankenphp.WithWorkerWatchMode(w.Watch),
159-
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
160-
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
161-
)
162-
} else {
163-
workerOpts = append(
164-
workerOpts,
165-
frankenphp.WithWorkerEnv(w.Env),
166-
frankenphp.WithWorkerWatchMode(w.Watch),
167-
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
168-
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
169-
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
170-
)
171-
}
172-
173-
opts = append(opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, workerOpts...))
156+
w.options = append(w.options,
157+
frankenphp.WithWorkerEnv(w.Env),
158+
frankenphp.WithWorkerWatchMode(w.Watch),
159+
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
160+
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
161+
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
162+
)
163+
164+
f.opts = append(f.opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, w.options...))
174165
}
175166

176167
frankenphp.Shutdown()
177-
if err := frankenphp.Init(opts...); err != nil {
168+
if err := frankenphp.Init(f.opts...); err != nil {
178169
return err
179170
}
180171

@@ -288,7 +279,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
288279
}
289280

290281
case "worker":
291-
wc, err := parseWorkerConfig(d)
282+
wc, err := unmarshalWorker(d)
292283
if err != nil {
293284
return err
294285
}

caddy/caddy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212

1313
const (
1414
defaultDocumentRoot = "public"
15-
defaultWatchPattern = "./**/*.{php,yaml,yml,twig,env}"
15+
defaultWatchPattern = "./**/*.{env,php,twig,yaml,yml}"
1616
)
1717

1818
func init() {

caddy/caddy_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1362,7 +1362,7 @@ func TestWorkerMatchDirective(t *testing.T) {
13621362
}
13631363
`, "caddyfile")
13641364

1365-
// worker is outside of public directory, match anyways
1365+
// worker is outside public directory, match anyway
13661366
tester.AssertGetResponse("http://localhost:"+testPort+"/matched-path", http.StatusOK, "requests:1")
13671367
tester.AssertGetResponse("http://localhost:"+testPort+"/matched-path/anywhere", http.StatusOK, "requests:2")
13681368

caddy/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ func TestModuleWorkerWithWatchConfiguration(t *testing.T) {
181181

182182
// Verify that the watch directories were set correctly
183183
require.Len(t, module.Workers[0].Watch, 3, "Expected three watch patterns")
184-
require.Equal(t, "./**/*.{php,yaml,yml,twig,env}", module.Workers[0].Watch[0], "First watch pattern should be the default")
184+
require.Equal(t, defaultWatchPattern, module.Workers[0].Watch[0], "First watch pattern should be the default")
185185
require.Equal(t, "./src/**/*.php", module.Workers[0].Watch[1], "Second watch pattern should match the configuration")
186186
require.Equal(t, "./config/**/*.yaml", module.Workers[0].Watch[2], "Third watch pattern should match the configuration")
187187
}

caddy/go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/dunglas/frankenphp/caddy
22

3-
go 1.25.0
3+
go 1.25.4
44

55
replace github.com/dunglas/frankenphp => ../
66

@@ -11,6 +11,7 @@ require (
1111
github.com/caddyserver/certmagic v0.25.0
1212
github.com/dunglas/caddy-cbrotli v1.0.1
1313
github.com/dunglas/frankenphp v1.10.1
14+
github.com/dunglas/mercure v0.21.2
1415
github.com/dunglas/mercure/caddy v0.21.2
1516
github.com/dunglas/vulcain/caddy v1.2.1
1617
github.com/prometheus/client_golang v1.23.2
@@ -59,10 +60,10 @@ require (
5960
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
6061
github.com/dlclark/regexp2 v1.11.5 // indirect
6162
github.com/dunglas/httpsfv v1.1.0 // indirect
62-
github.com/dunglas/mercure v0.21.2 // indirect
6363
github.com/dunglas/skipfilter v1.0.0 // indirect
6464
github.com/dunglas/vulcain v1.2.1 // indirect
6565
github.com/dustin/go-humanize v1.0.1 // indirect
66+
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 // indirect
6667
github.com/felixge/httpsnoop v1.0.4 // indirect
6768
github.com/fsnotify/fsnotify v1.9.0 // indirect
6869
github.com/fxamacker/cbor/v2 v2.9.0 // indirect

caddy/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ github.com/dunglas/vulcain/caddy v1.2.1/go.mod h1:8QrmLTfURmW2VgjTR6Gb9a53FrZjsp
159159
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
160160
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
161161
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
162+
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 h1:h3vVM6X45PK0mAk8NqiYNQGXTyhvXy1HQ5GhuQN4eeA=
163+
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146/go.mod h1:sVUOkwtftoj71nnJRG2S0oWNfXFdKpz/M9vK0z06nmM=
162164
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
163165
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
164166
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=

caddy/hotreload-skip.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//go:build nowatcher || nomercure
2+
3+
package caddy
4+
5+
import (
6+
"errors"
7+
8+
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
9+
)
10+
11+
type hotReloadContext struct {
12+
}
13+
14+
func (_ *FrankenPHPModule) configureHotReload(_ *FrankenPHPApp) error {
15+
return nil
16+
}
17+
18+
func (_ *FrankenPHPModule) unmarshalHotReload(d *caddyfile.Dispenser) error {
19+
return errors.New("hot reload support disabled")
20+
}

caddy/hotreload.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//go:build !nowatcher && !nomercure
2+
3+
package caddy
4+
5+
import (
6+
"bytes"
7+
"encoding/gob"
8+
"errors"
9+
"fmt"
10+
"hash/fnv"
11+
"net/url"
12+
13+
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
14+
"github.com/dunglas/frankenphp"
15+
)
16+
17+
const defaultHotReloadPattern = "./**/*.{css,env,gif,htm,html,jpg,jpeg,js,mjs,php,png,svg,twig,webp,xml,yaml,yml}"
18+
19+
type hotReloadContext struct {
20+
// HotReload specifies files to watch for file changes to trigger hot reloads updates. Supports the glob syntax.
21+
HotReload *hotReloadConfig `json:"hot_reload,omitempty"`
22+
}
23+
24+
type hotReloadConfig struct {
25+
Topic string `json:"topic"`
26+
Watch []string `json:"watch"`
27+
}
28+
29+
func (f *FrankenPHPModule) configureHotReload(app *FrankenPHPApp) error {
30+
if f.HotReload == nil {
31+
return nil
32+
}
33+
34+
if f.mercureHub == nil {
35+
return errors.New("unable to enable hot reloading: no Mercure hub configured")
36+
}
37+
38+
if len(f.HotReload.Watch) == 0 {
39+
f.HotReload.Watch = []string{defaultHotReloadPattern}
40+
}
41+
42+
if f.HotReload.Topic == "" {
43+
uid, err := uniqueID(f)
44+
if err != nil {
45+
return err
46+
}
47+
48+
f.HotReload.Topic = "https://frankenphp.dev/hot-reload/" + uid
49+
}
50+
51+
app.opts = append(app.opts, frankenphp.WithHotReload(f.HotReload.Topic, f.mercureHub, f.HotReload.Watch))
52+
f.preparedEnv["FRANKENPHP_HOT_RELOAD\x00"] = "/.well-known/mercure?topic=" + url.QueryEscape(f.HotReload.Topic)
53+
54+
return nil
55+
}
56+
57+
func (f *FrankenPHPModule) unmarshalHotReload(d *caddyfile.Dispenser) error {
58+
patterns := d.RemainingArgs()
59+
if len(patterns) > 0 {
60+
f.HotReload = &hotReloadConfig{
61+
Watch: patterns,
62+
}
63+
}
64+
65+
for d.NextBlock(1) {
66+
switch v := d.Val(); v {
67+
case "topic":
68+
if !d.NextArg() {
69+
return d.ArgErr()
70+
}
71+
72+
if f.HotReload == nil {
73+
f.HotReload = &hotReloadConfig{}
74+
}
75+
76+
f.HotReload.Topic = d.Val()
77+
78+
case "watch":
79+
patterns := d.RemainingArgs()
80+
if len(patterns) == 0 {
81+
return d.ArgErr()
82+
}
83+
84+
if f.HotReload == nil {
85+
f.HotReload = &hotReloadConfig{}
86+
}
87+
88+
f.HotReload.Watch = append(f.HotReload.Watch, patterns...)
89+
90+
default:
91+
return wrongSubDirectiveError("hot_reload", "topic, watch", v)
92+
}
93+
}
94+
95+
return nil
96+
}
97+
98+
func uniqueID(s any) (string, error) {
99+
var b bytes.Buffer
100+
101+
if err := gob.NewEncoder(&b).Encode(s); err != nil {
102+
return "", fmt.Errorf("unable to generate unique name: %w", err)
103+
}
104+
105+
h := fnv.New64a()
106+
if _, err := h.Write(b.Bytes()); err != nil {
107+
return "", fmt.Errorf("unable to generate unique name: %w", err)
108+
}
109+
110+
return fmt.Sprintf("%016x", h.Sum64()), nil
111+
}

caddy/hotreload_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//go:build !nowatcher && !nomercure
2+
3+
package caddy_test
4+
5+
import (
6+
"context"
7+
"net/http"
8+
"net/url"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
"sync"
13+
"testing"
14+
15+
"github.com/caddyserver/caddy/v2/caddytest"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestHotReload(t *testing.T) {
20+
const topic = "https://frankenphp.dev/hot-reload/test"
21+
22+
u := "/.well-known/mercure?topic=" + url.QueryEscape(topic)
23+
24+
tmpDir := t.TempDir()
25+
indexFile := filepath.Join(tmpDir, "index.php")
26+
27+
tester := caddytest.NewTester(t)
28+
tester.InitServer(`
29+
{
30+
debug
31+
skip_install_trust
32+
admin localhost:2999
33+
}
34+
35+
http://localhost:`+testPort+` {
36+
mercure {
37+
transport local
38+
subscriber_jwt TestKey
39+
anonymous
40+
}
41+
42+
php_server {
43+
root `+tmpDir+`
44+
hot_reload {
45+
topic `+topic+`
46+
watch `+tmpDir+`/*.php
47+
}
48+
}
49+
`, "caddyfile")
50+
51+
var connected, received sync.WaitGroup
52+
53+
connected.Add(1)
54+
received.Go(func() {
55+
cx, cancel := context.WithCancel(t.Context())
56+
req, _ := http.NewRequest(http.MethodGet, "http://localhost:"+testPort+u, nil)
57+
req = req.WithContext(cx)
58+
resp := tester.AssertResponseCode(req, http.StatusOK)
59+
60+
connected.Done()
61+
62+
var receivedBody strings.Builder
63+
64+
buf := make([]byte, 1024)
65+
for {
66+
_, err := resp.Body.Read(buf)
67+
require.NoError(t, err)
68+
69+
receivedBody.Write(buf)
70+
71+
if strings.Contains(receivedBody.String(), "index.php") {
72+
cancel()
73+
74+
break
75+
}
76+
}
77+
78+
require.NoError(t, resp.Body.Close())
79+
})
80+
81+
connected.Wait()
82+
83+
require.NoError(t, os.WriteFile(indexFile, []byte("<?=$_SERVER['FRANKENPHP_HOT_RELOAD'];"), 0644))
84+
85+
received.Wait()
86+
87+
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, u)
88+
}

0 commit comments

Comments
 (0)