Files
archived-frankenphp/caddy/app.go
Kévin Dunglas 225ca409d3 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>
2025-12-12 14:29:18 +01:00

330 lines
8.2 KiB
Go

package caddy
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/dunglas/frankenphp"
"github.com/dunglas/frankenphp/internal/fastabs"
)
var (
options []frankenphp.Option
optionsMU sync.RWMutex
)
// EXPERIMENTAL: RegisterWorkers provides a way for extensions to register frankenphp.Workers
func RegisterWorkers(name, fileName string, num int, wo ...frankenphp.WorkerOption) frankenphp.Workers {
w, opt := frankenphp.WithExtensionWorkers(name, fileName, num, wo...)
optionsMU.Lock()
options = append(options, opt)
optionsMU.Unlock()
return w
}
// FrankenPHPApp represents the global "frankenphp" directive in the Caddyfile
// it's responsible for starting up the global PHP instance and all threads
//
// {
// frankenphp {
// num_threads 20
// }
// }
type FrankenPHPApp struct {
// NumThreads sets the number of PHP threads to start. Default: 2x the number of available CPUs.
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 []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
}
var iniError = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
// CaddyModule returns the Caddy module information.
func (f FrankenPHPApp) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "frankenphp",
New: func() caddy.Module { return &f },
}
}
// Provision sets up the module.
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())
}
} else {
// if the http module is not configured (this should never happen) then collect the metrics by default
if errors.Is(err, caddy.ErrNotConfigured) {
f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry())
} else {
// the http module failed to provision due to invalid configuration
return fmt.Errorf("failed to provision caddy http: %w", err)
}
}
return nil
}
func (f *FrankenPHPApp) generateUniqueModuleWorkerName(filepath string) string {
var i uint
filepath, _ = fastabs.FastAbs(filepath)
name := "m#" + filepath
retry:
for _, wc := range f.Workers {
if wc.Name == name {
name = fmt.Sprintf("m#%s_%d", filepath, i)
i++
goto retry
}
}
return name
}
func (f *FrankenPHPApp) addModuleWorkers(workers ...workerConfig) ([]workerConfig, error) {
for i := range workers {
w := &workers[i]
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(w.FileName) {
w.FileName = filepath.Join(frankenphp.EmbeddedAppPath, w.FileName)
}
if w.Name == "" {
w.Name = f.generateUniqueModuleWorkerName(w.FileName)
} else if !strings.HasPrefix(w.Name, "m#") {
w.Name = "m#" + w.Name
}
f.Workers = append(f.Workers, *w)
}
return workers, nil
}
func (f *FrankenPHPApp) Start() error {
repl := caddy.NewReplacer()
optionsMU.RLock()
f.opts = append(f.opts, options...)
optionsMU.RUnlock()
f.opts = append(f.opts,
frankenphp.WithContext(f.ctx),
frankenphp.WithLogger(f.logger),
frankenphp.WithNumThreads(f.NumThreads),
frankenphp.WithMaxThreads(f.MaxThreads),
frankenphp.WithMetrics(f.metrics),
frankenphp.WithPhpIni(f.PhpIni),
frankenphp.WithMaxWaitTime(f.MaxWaitTime),
)
for _, w := range f.Workers {
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(f.opts...); err != nil {
return err
}
return nil
}
func (f *FrankenPHPApp) Stop() error {
ctx := caddy.ActiveContext()
if f.logger.Enabled(caddy.ActiveContext(), slog.LevelInfo) {
f.logger.LogAttrs(ctx, slog.LevelInfo, "FrankenPHP stopped 🐘")
}
// attempt a graceful shutdown if caddy is exiting
// note: Exiting() is currently marked as 'experimental'
// https://github.com/caddyserver/caddy/blob/e76405d55058b0a3e5ba222b44b5ef00516116aa/caddy.go#L810
if caddy.Exiting() {
frankenphp.DrainWorkers()
}
// reset the configuration so it doesn't bleed into later tests
f.Workers = nil
f.NumThreads = 0
f.MaxWaitTime = 0
optionsMU.Lock()
options = nil
optionsMU.Unlock()
return nil
}
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
for d.NextBlock(0) {
// when adding a new directive, also update the allowedDirectives error message
switch d.Val() {
case "num_threads":
if !d.NextArg() {
return d.ArgErr()
}
v, err := strconv.ParseUint(d.Val(), 10, 32)
if err != nil {
return err
}
f.NumThreads = int(v)
case "max_threads":
if !d.NextArg() {
return d.ArgErr()
}
if d.Val() == "auto" {
f.MaxThreads = -1
continue
}
v, err := strconv.ParseUint(d.Val(), 10, 32)
if err != nil {
return err
}
f.MaxThreads = int(v)
case "max_wait_time":
if !d.NextArg() {
return d.ArgErr()
}
v, err := time.ParseDuration(d.Val())
if err != nil {
return d.Err("max_wait_time must be a valid duration (example: 10s)")
}
f.MaxWaitTime = v
case "php_ini":
parseIniLine := func(d *caddyfile.Dispenser) error {
key := d.Val()
if !d.NextArg() {
return d.WrapErr(iniError)
}
if f.PhpIni == nil {
f.PhpIni = make(map[string]string)
}
f.PhpIni[key] = d.Val()
if d.NextArg() {
return d.WrapErr(iniError)
}
return nil
}
isBlock := false
for d.NextBlock(1) {
isBlock = true
err := parseIniLine(d)
if err != nil {
return err
}
}
if !isBlock {
if !d.NextArg() {
return d.WrapErr(iniError)
}
err := parseIniLine(d)
if err != nil {
return err
}
}
case "worker":
wc, err := unmarshalWorker(d)
if err != nil {
return err
}
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
}
if strings.HasPrefix(wc.Name, "m#") {
return d.Errf(`global worker names must not start with "m#": %q`, wc.Name)
}
// check for duplicate workers
for _, existingWorker := range f.Workers {
if existingWorker.FileName == wc.FileName {
return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName)
}
}
f.Workers = append(f.Workers, wc)
default:
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time", d.Val())
}
}
}
if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads {
return d.Err(`"max_threads"" must be greater than or equal to "num_threads"`)
}
return nil
}
func parseGlobalOption(d *caddyfile.Dispenser, _ any) (any, error) {
app := &FrankenPHPApp{}
if err := app.UnmarshalCaddyfile(d); err != nil {
return nil, err
}
// tell Caddyfile adapter that this is the JSON for an app
return httpcaddyfile.App{
Name: "frankenphp",
Value: caddyconfig.JSON(app, nil),
}, nil
}
var (
_ caddy.App = (*FrankenPHPApp)(nil)
_ caddy.Provisioner = (*FrankenPHPApp)(nil)
)