mirror of
https://github.com/dunglas/frankenphp.git
synced 2025-12-24 13:38:11 +08: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>
304 lines
7.6 KiB
Go
304 lines
7.6 KiB
Go
package caddy_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"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"
|
|
)
|
|
|
|
func TestRestartWorkerViaAdminApi(t *testing.T) {
|
|
tester := caddytest.NewTester(t)
|
|
tester.InitServer(`
|
|
{
|
|
skip_install_trust
|
|
admin localhost:2999
|
|
http_port `+testPort+`
|
|
|
|
frankenphp {
|
|
worker ../testdata/worker-with-counter.php 1
|
|
}
|
|
}
|
|
|
|
localhost:`+testPort+` {
|
|
route {
|
|
root ../testdata
|
|
rewrite worker-with-counter.php
|
|
php
|
|
}
|
|
}
|
|
`, "caddyfile")
|
|
|
|
tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1")
|
|
tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2")
|
|
|
|
assertAdminResponse(t, tester, "POST", "workers/restart", http.StatusOK, "workers restarted successfully\n")
|
|
|
|
tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1")
|
|
}
|
|
|
|
func TestShowTheCorrectThreadDebugStatus(t *testing.T) {
|
|
tester := caddytest.NewTester(t)
|
|
tester.InitServer(`
|
|
{
|
|
skip_install_trust
|
|
admin localhost:2999
|
|
http_port `+testPort+`
|
|
|
|
frankenphp {
|
|
num_threads 3
|
|
max_threads 6
|
|
worker ../testdata/worker-with-counter.php 1
|
|
worker ../testdata/index.php 1
|
|
}
|
|
}
|
|
|
|
localhost:`+testPort+` {
|
|
route {
|
|
root ../testdata
|
|
rewrite worker-with-counter.php
|
|
php
|
|
}
|
|
}
|
|
`, "caddyfile")
|
|
|
|
debugState := getDebugState(t, tester)
|
|
|
|
// assert that the correct threads are present in the thread info
|
|
assert.Equal(t, debugState.ThreadDebugStates[0].State, "ready")
|
|
assert.Contains(t, debugState.ThreadDebugStates[1].Name, "worker-with-counter.php")
|
|
assert.Contains(t, debugState.ThreadDebugStates[2].Name, "index.php")
|
|
assert.Equal(t, debugState.ReservedThreadCount, 3)
|
|
assert.Len(t, debugState.ThreadDebugStates, 3)
|
|
}
|
|
|
|
func TestAutoScaleWorkerThreads(t *testing.T) {
|
|
wg := sync.WaitGroup{}
|
|
maxTries := 10
|
|
requestsPerTry := 200
|
|
tester := caddytest.NewTester(t)
|
|
tester.InitServer(`
|
|
{
|
|
skip_install_trust
|
|
admin localhost:2999
|
|
http_port `+testPort+`
|
|
|
|
frankenphp {
|
|
max_threads 10
|
|
num_threads 2
|
|
worker ../testdata/sleep.php {
|
|
num 1
|
|
max_threads 3
|
|
}
|
|
}
|
|
}
|
|
|
|
localhost:`+testPort+` {
|
|
route {
|
|
root ../testdata
|
|
rewrite sleep.php
|
|
php
|
|
}
|
|
}
|
|
`, "caddyfile")
|
|
|
|
// spam an endpoint that simulates IO
|
|
endpoint := "http://localhost:" + testPort + "/?sleep=2&work=1000"
|
|
amountOfThreads := getNumThreads(t, tester)
|
|
|
|
// try to spawn the additional threads by spamming the server
|
|
for range maxTries {
|
|
wg.Add(requestsPerTry)
|
|
for range requestsPerTry {
|
|
go func() {
|
|
tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 2 ms and worked for 1000 iterations")
|
|
wg.Done()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
amountOfThreads = getNumThreads(t, tester)
|
|
if amountOfThreads > 2 {
|
|
break
|
|
}
|
|
}
|
|
|
|
assert.NotEqual(t, amountOfThreads, 2, "at least one thread should have been auto-scaled")
|
|
assert.LessOrEqual(t, amountOfThreads, 4, "at most 3 max_threads + 1 regular thread should be present")
|
|
}
|
|
|
|
// Note this test requires at least 2x40MB available memory for the process
|
|
func TestAutoScaleRegularThreadsOnAutomaticThreadLimit(t *testing.T) {
|
|
wg := sync.WaitGroup{}
|
|
maxTries := 10
|
|
requestsPerTry := 200
|
|
tester := caddytest.NewTester(t)
|
|
tester.InitServer(`
|
|
{
|
|
skip_install_trust
|
|
admin localhost:2999
|
|
http_port `+testPort+`
|
|
|
|
frankenphp {
|
|
max_threads auto
|
|
num_threads 1
|
|
php_ini memory_limit 40M # a reasonable limit for the test
|
|
}
|
|
}
|
|
|
|
localhost:`+testPort+` {
|
|
route {
|
|
root ../testdata
|
|
php
|
|
}
|
|
}
|
|
`, "caddyfile")
|
|
|
|
// spam an endpoint that simulates IO
|
|
endpoint := "http://localhost:" + testPort + "/sleep.php?sleep=2&work=1000"
|
|
amountOfThreads := getNumThreads(t, tester)
|
|
|
|
// try to spawn the additional threads by spamming the server
|
|
for range maxTries {
|
|
wg.Add(requestsPerTry)
|
|
for range requestsPerTry {
|
|
go func() {
|
|
tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 2 ms and worked for 1000 iterations")
|
|
wg.Done()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
amountOfThreads = getNumThreads(t, tester)
|
|
if amountOfThreads > 1 {
|
|
break
|
|
}
|
|
}
|
|
|
|
// assert that there are now more threads present
|
|
assert.NotEqual(t, amountOfThreads, 1)
|
|
}
|
|
|
|
func assertAdminResponse(t *testing.T, tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) {
|
|
adminUrl := "http://localhost:2999/frankenphp/"
|
|
r, err := http.NewRequest(method, adminUrl+path, nil)
|
|
assert.NoError(t, err)
|
|
if expectedBody == "" {
|
|
_ = tester.AssertResponseCode(r, expectedStatus)
|
|
return
|
|
}
|
|
_, _ = tester.AssertResponse(r, expectedStatus, expectedBody)
|
|
}
|
|
|
|
func getAdminResponseBody(t *testing.T, tester *caddytest.Tester, method string, path string) string {
|
|
adminUrl := "http://localhost:2999/frankenphp/"
|
|
r, err := http.NewRequest(method, adminUrl+path, nil)
|
|
assert.NoError(t, err)
|
|
resp := tester.AssertResponseCode(r, http.StatusOK)
|
|
defer resp.Body.Close()
|
|
bytes, err := io.ReadAll(resp.Body)
|
|
assert.NoError(t, err)
|
|
|
|
return string(bytes)
|
|
}
|
|
|
|
func getDebugState(t *testing.T, tester *caddytest.Tester) frankenphp.FrankenPHPDebugState {
|
|
t.Helper()
|
|
threadStates := getAdminResponseBody(t, tester, "GET", "threads")
|
|
|
|
var debugStates frankenphp.FrankenPHPDebugState
|
|
err := json.Unmarshal([]byte(threadStates), &debugStates)
|
|
assert.NoError(t, err)
|
|
|
|
return debugStates
|
|
}
|
|
|
|
func getNumThreads(t *testing.T, tester *caddytest.Tester) int {
|
|
t.Helper()
|
|
return len(getDebugState(t, tester).ThreadDebugStates)
|
|
}
|
|
|
|
func TestAddModuleWorkerViaAdminApi(t *testing.T) {
|
|
// Initialize a server with admin API enabled
|
|
tester := caddytest.NewTester(t)
|
|
tester.InitServer(`
|
|
{
|
|
skip_install_trust
|
|
admin localhost:2999
|
|
http_port `+testPort+`
|
|
}
|
|
|
|
localhost:`+testPort+` {
|
|
route {
|
|
root ../testdata
|
|
php
|
|
}
|
|
}
|
|
`, "caddyfile")
|
|
|
|
// Get initial debug state to check number of workers
|
|
initialDebugState := getDebugState(t, tester)
|
|
initialWorkerCount := 0
|
|
for _, thread := range initialDebugState.ThreadDebugStates {
|
|
if thread.Name != "" && thread.Name != "ready" {
|
|
initialWorkerCount++
|
|
}
|
|
}
|
|
|
|
// Create a Caddyfile configuration with a module worker
|
|
workerConfig := `
|
|
{
|
|
skip_install_trust
|
|
admin localhost:2999
|
|
http_port ` + testPort + `
|
|
}
|
|
|
|
localhost:` + testPort + ` {
|
|
route {
|
|
root ../testdata
|
|
php {
|
|
worker ../testdata/worker-with-counter.php 1
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
// Send the configuration to the admin API
|
|
adminUrl := "http://localhost:2999/load"
|
|
r, err := http.NewRequest("POST", adminUrl, bytes.NewBufferString(workerConfig))
|
|
assert.NoError(t, err)
|
|
r.Header.Set("Content-Type", "text/caddyfile")
|
|
resp := tester.AssertResponseCode(r, http.StatusOK)
|
|
defer resp.Body.Close()
|
|
|
|
// Get the updated debug state to check if the worker was added
|
|
updatedDebugState := getDebugState(t, tester)
|
|
updatedWorkerCount := 0
|
|
workerFound := false
|
|
filename, _ := fastabs.FastAbs("../testdata/worker-with-counter.php")
|
|
for _, thread := range updatedDebugState.ThreadDebugStates {
|
|
if thread.Name != "" && thread.Name != "ready" {
|
|
updatedWorkerCount++
|
|
if thread.Name == "Worker PHP Thread - "+filename {
|
|
workerFound = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Assert that the worker was added
|
|
assert.Greater(t, updatedWorkerCount, initialWorkerCount, "Worker count should have increased")
|
|
assert.True(t, workerFound, fmt.Sprintf("Worker with name %q should be found", "Worker PHP Thread - "+filename))
|
|
|
|
// Make a request to the worker to verify it's working
|
|
tester.AssertGetResponse("http://localhost:"+testPort+"/worker-with-counter.php", http.StatusOK, "requests:1")
|
|
}
|