Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
c62bd99
feat: hot reloading
dunglas Nov 24, 2025
ed37d2d
various fixes
dunglas Nov 25, 2025
f6083ad
fix
dunglas Nov 25, 2025
6074d60
fix nomercure and nowatcher tags
dunglas Nov 25, 2025
585f9d4
tidy
dunglas Nov 25, 2025
6a080d5
fix tests when workers are disabled
dunglas Nov 25, 2025
c94dba9
fix race condition
dunglas Nov 25, 2025
ae50db0
fixes
dunglas Nov 25, 2025
6d0ad18
Update internal/watcher/pattern.go
dunglas Nov 25, 2025
43afabb
Update internal/watcher/pattern.go
dunglas Nov 25, 2025
d533e3d
Update pattern.go
dunglas Nov 25, 2025
16fb1bb
Update pattern.go
dunglas Nov 25, 2025
ac26dd4
Update pattern.go
dunglas Nov 25, 2025
cf3129f
Update pattern.go
dunglas Nov 25, 2025
1d9c786
Update pattern.go
dunglas Nov 25, 2025
b115728
Update pattern.go
dunglas Nov 25, 2025
4574ed8
Update pattern.go
dunglas Nov 25, 2025
62903b2
review
dunglas Nov 26, 2025
9820e27
add omitempty for AssociatedPathName
dunglas Nov 26, 2025
5dd31d4
follow best practices for JSON document
dunglas Nov 28, 2025
26d98ea
wip
dunglas Dec 1, 2025
f968c3b
hot_reload directive
dunglas Dec 3, 2025
cfd22f0
Merge branch 'main' into feat/hot-reloading
dunglas Dec 3, 2025
c59164d
fix Caddy naming logic
dunglas Dec 3, 2025
4cebc69
remove remaining debug directive
dunglas Dec 3, 2025
1ff5365
Update pattern.go
dunglas Dec 3, 2025
ac69386
Update module.go
dunglas Dec 3, 2025
679e971
Update frankenphp.go
dunglas Dec 3, 2025
67de799
Update module.go
dunglas Dec 3, 2025
feff832
add test and fix bugs with symlinks
dunglas Dec 4, 2025
024cc6c
Merge branch 'feat/hot-reloading' of github.com:php/frankenphp into f…
dunglas Dec 4, 2025
9038cb6
simplify restart workers logic
dunglas Dec 4, 2025
2402892
simplify
dunglas Dec 5, 2025
417a3ab
fix AssociatedPathName
dunglas Dec 5, 2025
7062abb
simplify
dunglas Dec 5, 2025
ac67a68
fix tests
dunglas Dec 5, 2025
a233e30
simplify
dunglas Dec 5, 2025
77da2a1
fix tests
dunglas Dec 5, 2025
b8257ee
cleanup
dunglas Dec 5, 2025
80bc973
fix tests
dunglas Dec 5, 2025
3e3683f
Merge branch 'main' into feat/hot-reloading
dunglas Dec 9, 2025
2f1897f
use watcher-go
dunglas Dec 10, 2025
cadeb42
fix tests
dunglas Dec 10, 2025
14ebc1d
remove remaining debug log
dunglas Dec 11, 2025
25885da
new config
dunglas Dec 11, 2025
cabb101
fix tests
dunglas Dec 11, 2025
51a6283
fix tests
dunglas Dec 11, 2025
73f5a69
fix tests provided by @withinboredom
dunglas Dec 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion caddy/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import (
"bytes"
"encoding/json"
"fmt"
"github.com/dunglas/frankenphp/internal/fastabs"
"io"
"net/http"
"sync"
"testing"

"github.com/dunglas/frankenphp/internal/fastabs"

"github.com/caddyserver/caddy/v2/caddytest"
"github.com/dunglas/frankenphp"
"github.com/stretchr/testify/assert"
Expand Down
45 changes: 18 additions & 27 deletions caddy/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@ type FrankenPHPApp struct {
NumThreads int `json:"num_threads,omitempty"`
// MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads
MaxThreads int `json:"max_threads,omitempty"`
// Workers configures the worker scripts to start.
// Workers configures the worker scripts to start
Workers []workerConfig `json:"workers,omitempty"`
// Overwrites the default php ini configuration
PhpIni map[string]string `json:"php_ini,omitempty"`
// The maximum amount of time a request may be stalled waiting for a thread
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"`

opts []frankenphp.Option
metrics frankenphp.Metrics
ctx context.Context
logger *slog.Logger
Expand All @@ -76,6 +77,9 @@ func (f *FrankenPHPApp) Provision(ctx caddy.Context) error {
f.ctx = ctx
f.logger = ctx.Slogger()

// We have at least 7 hardcoded options
f.opts = make([]frankenphp.Option, 0, 7+len(options))

if httpApp, err := ctx.AppIfConfigured("http"); err == nil {
if httpApp.(*caddyhttp.App).Metrics != nil {
f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry())
Expand Down Expand Up @@ -135,11 +139,10 @@ func (f *FrankenPHPApp) Start() error {
repl := caddy.NewReplacer()

optionsMU.RLock()
opts := make([]frankenphp.Option, 0, len(options)+len(f.Workers)+7)
opts = append(opts, options...)
f.opts = append(f.opts, options...)
optionsMU.RUnlock()

opts = append(opts,
f.opts = append(f.opts,
frankenphp.WithContext(f.ctx),
frankenphp.WithLogger(f.logger),
frankenphp.WithNumThreads(f.NumThreads),
Expand All @@ -150,31 +153,19 @@ func (f *FrankenPHPApp) Start() error {
)

for _, w := range f.Workers {
workerOpts := make([]frankenphp.WorkerOption, 0, len(w.requestOptions)+4)

if w.requestOptions == nil {
workerOpts = append(workerOpts,
frankenphp.WithWorkerEnv(w.Env),
frankenphp.WithWorkerWatchMode(w.Watch),
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
)
} else {
workerOpts = append(
workerOpts,
frankenphp.WithWorkerEnv(w.Env),
frankenphp.WithWorkerWatchMode(w.Watch),
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
)
}

opts = append(opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, workerOpts...))
w.options = append(w.options,
frankenphp.WithWorkerEnv(w.Env),
frankenphp.WithWorkerWatchMode(w.Watch),
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
)

f.opts = append(f.opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, w.options...))
}

frankenphp.Shutdown()
if err := frankenphp.Init(opts...); err != nil {
if err := frankenphp.Init(f.opts...); err != nil {
return err
}

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

case "worker":
wc, err := parseWorkerConfig(d)
wc, err := unmarshalWorker(d)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion caddy/caddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

const (
defaultDocumentRoot = "public"
defaultWatchPattern = "./**/*.{php,yaml,yml,twig,env}"
defaultWatchPattern = "./**/*.{env,php,twig,yaml,yml}"
)

func init() {
Expand Down
2 changes: 1 addition & 1 deletion caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1362,7 +1362,7 @@ func TestWorkerMatchDirective(t *testing.T) {
}
`, "caddyfile")

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

Expand Down
2 changes: 1 addition & 1 deletion caddy/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ func TestModuleWorkerWithWatchConfiguration(t *testing.T) {

// Verify that the watch directories were set correctly
require.Len(t, module.Workers[0].Watch, 3, "Expected three watch patterns")
require.Equal(t, "./**/*.{php,yaml,yml,twig,env}", module.Workers[0].Watch[0], "First watch pattern should be the default")
require.Equal(t, defaultWatchPattern, module.Workers[0].Watch[0], "First watch pattern should be the default")
require.Equal(t, "./src/**/*.php", module.Workers[0].Watch[1], "Second watch pattern should match the configuration")
require.Equal(t, "./config/**/*.yaml", module.Workers[0].Watch[2], "Third watch pattern should match the configuration")
}
Expand Down
5 changes: 3 additions & 2 deletions caddy/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/dunglas/frankenphp/caddy

go 1.25.0
go 1.25.4

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

Expand All @@ -11,6 +11,7 @@ require (
github.com/caddyserver/certmagic v0.25.0
github.com/dunglas/caddy-cbrotli v1.0.1
github.com/dunglas/frankenphp v1.10.1
github.com/dunglas/mercure v0.21.2
github.com/dunglas/mercure/caddy v0.21.2
github.com/dunglas/vulcain/caddy v1.2.1
github.com/prometheus/client_golang v1.23.2
Expand Down Expand Up @@ -59,10 +60,10 @@ require (
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/dunglas/mercure v0.21.2 // indirect
github.com/dunglas/skipfilter v1.0.0 // indirect
github.com/dunglas/vulcain v1.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions caddy/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ github.com/dunglas/vulcain/caddy v1.2.1/go.mod h1:8QrmLTfURmW2VgjTR6Gb9a53FrZjsp
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 h1:h3vVM6X45PK0mAk8NqiYNQGXTyhvXy1HQ5GhuQN4eeA=
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146/go.mod h1:sVUOkwtftoj71nnJRG2S0oWNfXFdKpz/M9vK0z06nmM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
Expand Down
20 changes: 20 additions & 0 deletions caddy/hotreload-skip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//go:build nowatcher || nomercure

package caddy

import (
"errors"

"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)

type hotReloadContext struct {
}

func (_ *FrankenPHPModule) configureHotReload(_ *FrankenPHPApp) error {
return nil
}

func (_ *FrankenPHPModule) unmarshalHotReload(d *caddyfile.Dispenser) error {
return errors.New("hot reload support disabled")
}
111 changes: 111 additions & 0 deletions caddy/hotreload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//go:build !nowatcher && !nomercure

package caddy

import (
"bytes"
"encoding/gob"
"errors"
"fmt"
"hash/fnv"
"net/url"

"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/dunglas/frankenphp"
)

const defaultHotReloadPattern = "./**/*.{css,env,gif,htm,html,jpg,jpeg,js,mjs,php,png,svg,twig,webp,xml,yaml,yml}"

type hotReloadContext struct {
// HotReload specifies files to watch for file changes to trigger hot reloads updates. Supports the glob syntax.
HotReload *hotReloadConfig `json:"hot_reload,omitempty"`
}

type hotReloadConfig struct {
Topic string `json:"topic"`
Watch []string `json:"watch"`
}

func (f *FrankenPHPModule) configureHotReload(app *FrankenPHPApp) error {
if f.HotReload == nil {
return nil
}

if f.mercureHub == nil {
return errors.New("unable to enable hot reloading: no Mercure hub configured")
}

if len(f.HotReload.Watch) == 0 {
f.HotReload.Watch = []string{defaultHotReloadPattern}
}

if f.HotReload.Topic == "" {
uid, err := uniqueID(f)
if err != nil {
return err
}

f.HotReload.Topic = "https://frankenphp.dev/hot-reload/" + uid
}

app.opts = append(app.opts, frankenphp.WithHotReload(f.HotReload.Topic, f.mercureHub, f.HotReload.Watch))
f.preparedEnv["FRANKENPHP_HOT_RELOAD\x00"] = "/.well-known/mercure?topic=" + url.QueryEscape(f.HotReload.Topic)

return nil
}

func (f *FrankenPHPModule) unmarshalHotReload(d *caddyfile.Dispenser) error {
patterns := d.RemainingArgs()
if len(patterns) > 0 {
f.HotReload = &hotReloadConfig{
Watch: patterns,
}
}

for d.NextBlock(1) {
switch v := d.Val(); v {
case "topic":
if !d.NextArg() {
return d.ArgErr()
}

if f.HotReload == nil {
f.HotReload = &hotReloadConfig{}
}

f.HotReload.Topic = d.Val()

case "watch":
patterns := d.RemainingArgs()
if len(patterns) == 0 {
return d.ArgErr()
}

if f.HotReload == nil {
f.HotReload = &hotReloadConfig{}
}

f.HotReload.Watch = append(f.HotReload.Watch, patterns...)

default:
return wrongSubDirectiveError("hot_reload", "topic, watch", v)
}
}

return nil
}

func uniqueID(s any) (string, error) {
var b bytes.Buffer

if err := gob.NewEncoder(&b).Encode(s); err != nil {
return "", fmt.Errorf("unable to generate unique name: %w", err)
}

h := fnv.New64a()
if _, err := h.Write(b.Bytes()); err != nil {
return "", fmt.Errorf("unable to generate unique name: %w", err)
}

return fmt.Sprintf("%016x", h.Sum64()), nil
}
88 changes: 88 additions & 0 deletions caddy/hotreload_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//go:build !nowatcher && !nomercure

package caddy_test

import (
"context"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"

"github.com/caddyserver/caddy/v2/caddytest"
"github.com/stretchr/testify/require"
)

func TestHotReload(t *testing.T) {
const topic = "https://frankenphp.dev/hot-reload/test"

u := "/.well-known/mercure?topic=" + url.QueryEscape(topic)

tmpDir := t.TempDir()
indexFile := filepath.Join(tmpDir, "index.php")

tester := caddytest.NewTester(t)
tester.InitServer(`
{
debug
skip_install_trust
admin localhost:2999
}

http://localhost:`+testPort+` {
mercure {
transport local
subscriber_jwt TestKey
anonymous
}

php_server {
root `+tmpDir+`
hot_reload {
topic `+topic+`
watch `+tmpDir+`/*.php
}
}
`, "caddyfile")

var connected, received sync.WaitGroup

connected.Add(1)
received.Go(func() {
cx, cancel := context.WithCancel(t.Context())
req, _ := http.NewRequest(http.MethodGet, "http://localhost:"+testPort+u, nil)
req = req.WithContext(cx)
resp := tester.AssertResponseCode(req, http.StatusOK)

connected.Done()

var receivedBody strings.Builder

buf := make([]byte, 1024)
for {
_, err := resp.Body.Read(buf)
require.NoError(t, err)

receivedBody.Write(buf)

if strings.Contains(receivedBody.String(), "index.php") {
cancel()

break
}
}

require.NoError(t, resp.Body.Close())
})

connected.Wait()

require.NoError(t, os.WriteFile(indexFile, []byte("<?=$_SERVER['FRANKENPHP_HOT_RELOAD'];"), 0644))

received.Wait()

tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, u)
}
Loading
Loading