├── app.tar ├── app_checksum.txt ├── testdata ├── hello.txt ├── files │ ├── .gitignore │ └── static.txt ├── file-stream.txt ├── hello.php ├── env │ ├── import-env.php │ ├── env.php │ ├── overwrite-env.php │ ├── putenv.php │ ├── remember-env.php │ └── test-env.php ├── dirindex │ └── index.php ├── non-worker.php ├── transition-regular.php ├── command.php ├── echo.php ├── phpinfo.php ├── autoloader-require.php ├── persistent-object-require.php ├── cookies.php ├── server-variable.php ├── transition-worker-1.php ├── large-response.php ├── ini.php ├── log-error_log.php ├── exception.php ├── die.php ├── index.php ├── input.php ├── request-headers.php ├── super-globals.php ├── message-worker.php ├── failing-worker.php ├── worker-with-env.php ├── benchmark.Caddyfile ├── fiber-basic.php ├── _executor.php ├── finish-request.php ├── autoloader.php ├── transition-worker-2.php ├── mercure-publish.php ├── performance │ ├── start-server.sh │ ├── k6.Caddyfile │ ├── hello-world.js │ ├── flamegraph.sh │ ├── performance-testing.md │ ├── computation.js │ ├── hanging-requests.js │ ├── api.js │ ├── database.js │ ├── timeouts.js │ └── perf-test.sh ├── integration │ ├── invalid_signature.go │ ├── type_mismatch.go │ ├── basic_function.go │ ├── namespace.go │ ├── constants.go │ ├── class_methods.go │ └── callable.go ├── large-request.php ├── only-headers.php ├── worker-env.php ├── fiber-no-cgo.php ├── timeout.php ├── worker-with-counter.php ├── early-hints.php ├── session.php ├── worker-restart.php ├── flush.php ├── connection_status.php ├── worker.php ├── headers.php ├── response-headers.php ├── worker-getopt.php ├── file-stream.php ├── persistent-object.php ├── file-upload.php ├── log-frankenphp_log.php ├── Caddyfile ├── dd.php ├── sleep.php ├── server-all-vars-ordered.php └── server-all-vars-ordered.txt ├── .clang-format-ignore ├── .gitleaksignore ├── frankenphp.png ├── caddy ├── br.go ├── br-skip.go ├── mercure-skip.go ├── frankenphp │ ├── main.go │ └── Caddyfile ├── hotreload-skip.go ├── mercure.go ├── watcher_test.go ├── php-cli.go ├── caddy.go ├── extinit.go ├── module_test.go ├── admin.go ├── hotreload_test.go └── hotreload.go ├── internal ├── testext │ ├── testdata │ │ └── index.php │ ├── ext_test.go │ ├── extension.h │ ├── extensions.c │ └── exttest.go ├── memory │ ├── memory_others.go │ └── memory_linux.go ├── fastabs │ ├── filepath.go │ └── filepath_unix.go ├── extgen │ ├── templates │ │ ├── extension.h.tpl │ │ ├── README.md.tpl │ │ ├── stub.php.tpl │ │ └── extension.go.tpl │ ├── errors.go │ ├── parser.go │ ├── docs.go │ ├── utils.go │ ├── nsparser.go │ ├── stub.go │ ├── hfile.go │ ├── utils_namespace_test.go │ ├── arginfo.go │ ├── cfile.go │ ├── nodes.go │ └── phpfunc.go ├── cpu │ ├── cpu_windows.go │ └── cpu_unix.go ├── testcli │ └── main.go ├── phpheaders │ └── phpheaders_test.go ├── testserver │ └── main.go └── state │ └── state_test.go ├── docs ├── mercure-hub.png ├── digitalocean-dns.png ├── digitalocean-droplet.png ├── cn │ ├── early-hints.md │ ├── mercure.md │ ├── classic.md │ ├── github-actions.md │ ├── metrics.md │ └── x-sendfile.md ├── ja │ ├── early-hints.md │ ├── mercure.md │ ├── classic.md │ ├── github-actions.md │ ├── metrics.md │ └── x-sendfile.md ├── early-hints.md ├── ru │ ├── early-hints.md │ ├── mercure.md │ ├── metrics.md │ └── github-actions.md ├── tr │ ├── early-hints.md │ ├── mercure.md │ └── github-actions.md ├── fr │ ├── early-hints.md │ ├── mercure.md │ ├── classic.md │ ├── metrics.md │ ├── github-actions.md │ └── x-sendfile.md ├── pt-br │ ├── early-hints.md │ ├── mercure.md │ ├── classic.md │ ├── metrics.md │ ├── github-actions.md │ └── x-sendfile.md ├── classic.md ├── metrics.md ├── github-actions.md └── x-sendfile.md ├── .hadolint.yaml ├── .markdown-lint.yaml ├── zizmor.yaml ├── .golangci.yaml ├── package ├── debian │ ├── prerm.sh │ ├── postrm.sh │ ├── frankenphp.service │ └── postinst.sh ├── rhel │ ├── preuninstall.sh │ ├── preinstall.sh │ ├── frankenphp.service │ ├── postuninstall.sh │ └── postinstall.sh └── Caddyfile ├── .editorconfig ├── .dockerignore ├── .gitignore ├── go.sh ├── cgo.go ├── reload_test.sh ├── watcher-skip.go ├── mercure-skip.go ├── ext.go ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.yaml │ └── bug_report.yaml ├── dependabot.yaml ├── actions │ └── watcher │ │ └── action.yaml └── workflows │ └── lint.yaml ├── log_test.go ├── SECURITY.md ├── types.h ├── LICENSE ├── mercure_test.go ├── watcher.go ├── hotreload.go ├── types.c ├── release.sh ├── threadinactive.go ├── scaling_test.go ├── debugstate.go ├── frankenphp.stub.php ├── workerextension.go ├── dev-alpine.Dockerfile ├── threadtasks_test.go ├── dev.Dockerfile ├── watcher_test.go ├── workerextension_test.go ├── go.mod ├── mercure.go ├── frankenphp.h └── static-builder-musl.Dockerfile /app.tar: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app_checksum.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/hello.txt: -------------------------------------------------------------------------------- 1 | Hello 2 | -------------------------------------------------------------------------------- /testdata/files/.gitignore: -------------------------------------------------------------------------------- 1 | test.txt 2 | -------------------------------------------------------------------------------- /.clang-format-ignore: -------------------------------------------------------------------------------- 1 | frankenphp_arginfo.h 2 | -------------------------------------------------------------------------------- /testdata/file-stream.txt: -------------------------------------------------------------------------------- 1 | word1word2word3 2 | -------------------------------------------------------------------------------- /testdata/files/static.txt: -------------------------------------------------------------------------------- 1 | Hello from file 2 | -------------------------------------------------------------------------------- /.gitleaksignore: -------------------------------------------------------------------------------- 1 | /github/workspace/docs/mercure.md:jwt:88 2 | -------------------------------------------------------------------------------- /testdata/hello.php: -------------------------------------------------------------------------------- 1 | /dev/null || true 6 | fi 7 | -------------------------------------------------------------------------------- /testdata/exception.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | extern zend_module_entry module1_entry; 7 | extern zend_module_entry module2_entry; 8 | 9 | #endif 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.sh] 8 | indent_style = tab 9 | tab_width = 4 10 | 11 | [*.Dockerfile] 12 | indent_style = tab 13 | tab_width = 4 14 | -------------------------------------------------------------------------------- /testdata/failing-worker.php: -------------------------------------------------------------------------------- 1 | start(); 9 | }; 10 | -------------------------------------------------------------------------------- /package/rhel/preuninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$1" -eq 0 ] && [ -x "/usr/lib/systemd/systemd-update-helper" ]; then 4 | # Package removal, not upgrade 5 | /usr/lib/systemd/systemd-update-helper remove-system-units frankenphp.service || : 6 | fi 7 | -------------------------------------------------------------------------------- /testdata/_executor.php: -------------------------------------------------------------------------------- 1 | 4 | import "C" 5 | 6 | // export_php:function invalid_return_type(string $str): unsupported_type 7 | func invalid_return_type(s *C.zend_string) int { 8 | return 42 9 | } 10 | -------------------------------------------------------------------------------- /testdata/large-request.php: -------------------------------------------------------------------------------- 1 | start(); 9 | 10 | $fiber->resume(); 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /caddy/mercure-skip.go: -------------------------------------------------------------------------------- 1 | //go:build nomercure 2 | 3 | package caddy 4 | 5 | type mercureContext struct { 6 | } 7 | 8 | func (f *FrankenPHPModule) configureHotReload(_ *FrankenPHPApp) error { 9 | return nil 10 | } 11 | 12 | func (f *FrankenPHPModule) assignMercureHub(_ caddy.Context) { 13 | } 14 | -------------------------------------------------------------------------------- /package/rhel/preinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | getent group frankenphp &>/dev/null || 4 | groupadd -r frankenphp &>/dev/null 5 | getent passwd frankenphp &>/dev/null || 6 | useradd -r -g frankenphp -d /var/lib/frankenphp -s /sbin/nologin -c 'FrankenPHP web server' frankenphp &>/dev/null 7 | exit 0 8 | -------------------------------------------------------------------------------- /testdata/env/env.php: -------------------------------------------------------------------------------- 1 | "$key=" . $_ENV[$key], $keys)); 10 | }; 11 | -------------------------------------------------------------------------------- /testdata/timeout.php: -------------------------------------------------------------------------------- 1 | ; rel=preload; as=style'); 7 | header("Request: {$_GET['i']}"); 8 | headers_send(103); 9 | 10 | header_remove('Link'); 11 | 12 | echo 'Hello'; 13 | }; 14 | -------------------------------------------------------------------------------- /internal/fastabs/filepath.go: -------------------------------------------------------------------------------- 1 | //go:build !unix 2 | 3 | package fastabs 4 | 5 | import ( 6 | "path/filepath" 7 | ) 8 | 9 | // FastAbs can't be optimized on Windows because the 10 | // syscall.FullPath function takes an input. 11 | func FastAbs(path string) (string, error) { 12 | return filepath.Abs(path) 13 | } 14 | -------------------------------------------------------------------------------- /testdata/session.php: -------------------------------------------------------------------------------- 1 | 5 | #include 6 | 7 | extern zend_module_entry {{.BaseName}}_module_entry; 8 | 9 | {{if .Constants}} 10 | /* User defined constants */{{end}} 11 | {{range .Constants}}#define {{.Name}} {{.CValue}} 12 | {{end}} 13 | #endif 14 | -------------------------------------------------------------------------------- /reload_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | for ((i = 0; i < 100; i++)); do 3 | curl --no-progress-meter -o /dev/null http://localhost:2019/config/apps/frankenphp -: --no-progress-meter -o /dev/null -H 'Cache-Control: must-revalidate' -H 'Content-Type: application/json' --data-binary '{"workers":[{"file_name":"./index.php"}]}' -X PATCH http://localhost:2019/config/apps/frankenphp 4 | done 5 | -------------------------------------------------------------------------------- /testdata/flush.php: -------------------------------------------------------------------------------- 1 | id . "\n"; 12 | echo 'object id: '. spl_object_id($foo); 13 | }; 14 | -------------------------------------------------------------------------------- /caddy/frankenphp/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | caddycmd "github.com/caddyserver/caddy/v2/cmd" 5 | 6 | // plug in Caddy modules here. 7 | _ "github.com/caddyserver/caddy/v2/modules/standard" 8 | _ "github.com/dunglas/caddy-cbrotli" 9 | _ "github.com/dunglas/frankenphp/caddy" 10 | _ "github.com/dunglas/mercure/caddy" 11 | _ "github.com/dunglas/vulcain/caddy" 12 | ) 13 | 14 | func main() { 15 | caddycmd.Main() 16 | } 17 | -------------------------------------------------------------------------------- /internal/testcli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/dunglas/frankenphp" 8 | ) 9 | 10 | func main() { 11 | if len(os.Args) <= 1 { 12 | log.Println("Usage: testcli script.php") 13 | os.Exit(1) 14 | } 15 | 16 | if len(os.Args) == 3 && os.Args[1] == "-r" { 17 | os.Exit(frankenphp.ExecutePHPCode(os.Args[2])) 18 | } 19 | 20 | os.Exit(frankenphp.ExecuteScriptCLI(os.Args[1], os.Args)) 21 | } 22 | -------------------------------------------------------------------------------- /watcher-skip.go: -------------------------------------------------------------------------------- 1 | //go:build nowatcher 2 | 3 | package frankenphp 4 | 5 | import "errors" 6 | 7 | type hotReloadOpt struct { 8 | } 9 | 10 | var errWatcherNotEnabled = errors.New("watcher support is not enabled") 11 | 12 | func initWatchers(o *opt) error { 13 | for _, o := range o.workers { 14 | if len(o.watch) != 0 { 15 | return errWatcherNotEnabled 16 | } 17 | } 18 | 19 | return nil 20 | } 21 | 22 | func drainWatchers() { 23 | } 24 | -------------------------------------------------------------------------------- /caddy/hotreload-skip.go: -------------------------------------------------------------------------------- 1 | //go:build nowatcher || nomercure 2 | 3 | package caddy 4 | 5 | import ( 6 | "errors" 7 | 8 | "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 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 | } 21 | -------------------------------------------------------------------------------- /docs/cn/early-hints.md: -------------------------------------------------------------------------------- 1 | # 早期提示 2 | 3 | FrankenPHP 原生支持 [103 Early Hints 状态码](https://developer.chrome.com/blog/early-hints/)。 4 | 使用早期提示可以将网页的加载时间缩短 30%。 5 | 6 | ```php 7 | ; rel=preload; as=style'); 10 | headers_send(103); 11 | 12 | // 慢速算法和 SQL 查询 13 | 14 | echo <<<'HTML' 15 | 16 | Hello FrankenPHP 17 | 18 | HTML; 19 | ``` 20 | 21 | 早期提示由普通模式和 [worker](worker.md) 模式支持。 22 | -------------------------------------------------------------------------------- /testdata/integration/type_mismatch.go: -------------------------------------------------------------------------------- 1 | package testintegration 2 | 3 | // #include 4 | import "C" 5 | 6 | // export_php:function mismatched_param_type(int $value): int 7 | func mismatched_param_type(value string) int64 { 8 | return 0 9 | } 10 | 11 | // export_php:class BadClass 12 | type BadClassStruct struct { 13 | Value int 14 | } 15 | 16 | // export_php:method BadClass::wrongReturnType(): string 17 | func (bc *BadClassStruct) WrongReturnType() int { 18 | return bc.Value 19 | } 20 | -------------------------------------------------------------------------------- /mercure-skip.go: -------------------------------------------------------------------------------- 1 | //go:build nomercure 2 | 3 | package frankenphp 4 | 5 | // #include 6 | // #include 7 | import "C" 8 | 9 | type mercureContext struct { 10 | } 11 | 12 | //export go_mercure_publish 13 | func go_mercure_publish(threadIndex C.uintptr_t, topics *C.struct__zval_struct, data *C.zend_string, private bool, id, typ *C.zend_string, retry uint64) (generatedID *C.zend_string, error C.short) { 14 | return nil, 3 15 | } 16 | 17 | func (w *worker) configureMercure(_ *workerOpt) { 18 | } 19 | -------------------------------------------------------------------------------- /testdata/file-upload.php: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | HTML; 18 | }; 19 | -------------------------------------------------------------------------------- /testdata/performance/hello-world.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | 3 | /** 4 | * 'Hello world' tests the raw server performance. 5 | */ 6 | export const options = { 7 | stages: [ 8 | { duration: "5s", target: 100 }, 9 | { duration: "20s", target: 400 }, 10 | { duration: "5s", target: 0 }, 11 | ], 12 | thresholds: { 13 | http_req_failed: ["rate<0.01"], 14 | }, 15 | }; 16 | 17 | /* global __ENV */ 18 | export default function () { 19 | http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php`); 20 | } 21 | -------------------------------------------------------------------------------- /docs/ja/early-hints.md: -------------------------------------------------------------------------------- 1 | # Early Hints 2 | 3 | FrankenPHPは[103 Early Hints ステータスコード](https://developer.chrome.com/blog/early-hints/)をネイティブサポートしています。 4 | Early Hintsを使用することで、ウェブページの読み込み時間を30%改善できます。 5 | 6 | ```php 7 | ; rel=preload; as=style'); 10 | headers_send(103); 11 | 12 | // 遅いアルゴリズムとSQLクエリ 🤪 13 | 14 | echo <<<'HTML' 15 | 16 | Hello FrankenPHP 17 | 18 | HTML; 19 | ``` 20 | 21 | Early Hintsは通常モードと[ワーカー](worker.md)モードの両方でサポートされています。 22 | -------------------------------------------------------------------------------- /package/Caddyfile: -------------------------------------------------------------------------------- 1 | # The Caddyfile is an easy way to configure FrankenPHP and the Caddy web server. 2 | # 3 | # https://frankenphp.dev/docs/config 4 | # https://caddyserver.com/docs/caddyfile 5 | { 6 | frankenphp 7 | } 8 | 9 | http:// { 10 | root /usr/share/frankenphp/ 11 | encode zstd br gzip 12 | 13 | php_server 14 | } 15 | 16 | # As an alternative to editing the above site block, you can add your own site 17 | # block files in the Caddyfile.d directory, and they will be included as long 18 | # as they use the .caddyfile extension. 19 | import Caddyfile.d/*.caddyfile 20 | -------------------------------------------------------------------------------- /docs/cn/mercure.md: -------------------------------------------------------------------------------- 1 | # 实时 2 | 3 | FrankenPHP 配备了内置的 [Mercure](https://mercure.rocks) 中心! 4 | Mercure 允许将事件实时推送到所有连接的设备:它们将立即收到 JavaScript 事件。 5 | 6 | 无需 JS 库或 SDK! 7 | 8 | ![Mercure](../mercure-hub.png) 9 | 10 | 要启用 Mercure Hub,请按照 [Mercure 网站](https://mercure.rocks/docs/hub/config) 中的说明更新 `Caddyfile`。 11 | 12 | Mercure hub 的路径是`/.well-known/mercure`. 13 | 在 Docker 中运行 FrankenPHP 时,完整的发送 URL 将类似于 `http://php/.well-known/mercure` (其中 `php` 是运行 FrankenPHP 的容器名称)。 14 | 15 | 要从你的代码中推送 Mercure 更新,我们推荐 [Symfony Mercure Component](https://symfony.com/components/Mercure)(不需要 Symfony 框架来使用)。 16 | -------------------------------------------------------------------------------- /testdata/log-frankenphp_log.php: -------------------------------------------------------------------------------- 1 | 1, 8 | ]); 9 | 10 | frankenphp_log("some info message {$_GET['i']}", FRANKENPHP_LOG_LEVEL_INFO, [ 11 | "key string" => "string", 12 | ]); 13 | 14 | frankenphp_log("some warn message {$_GET['i']}", FRANKENPHP_LOG_LEVEL_WARN); 15 | 16 | frankenphp_log("some error message {$_GET['i']}", FRANKENPHP_LOG_LEVEL_ERROR, [ 17 | "err" => ["a", "v"], 18 | ]); 19 | }; 20 | -------------------------------------------------------------------------------- /testdata/env/remember-env.php: -------------------------------------------------------------------------------- 1 | ; rel=preload; as=style'); 10 | headers_send(103); 11 | 12 | // your slow algorithms and SQL queries 🤪 13 | 14 | echo <<<'HTML' 15 | 16 | Hello FrankenPHP 17 | 18 | HTML; 19 | ``` 20 | 21 | Early Hints are supported both by the normal and the [worker](worker.md) modes. 22 | -------------------------------------------------------------------------------- /ext.go: -------------------------------------------------------------------------------- 1 | package frankenphp 2 | 3 | //#include "frankenphp.h" 4 | import "C" 5 | import ( 6 | "sync" 7 | "unsafe" 8 | ) 9 | 10 | var ( 11 | extensions []*C.zend_module_entry 12 | registerOnce sync.Once 13 | ) 14 | 15 | // RegisterExtension registers a new PHP extension. 16 | func RegisterExtension(me unsafe.Pointer) { 17 | extensions = append(extensions, (*C.zend_module_entry)(me)) 18 | } 19 | 20 | func registerExtensions() { 21 | if len(extensions) == 0 { 22 | return 23 | } 24 | 25 | registerOnce.Do(func() { 26 | C.register_extensions(extensions[0], C.int(len(extensions))) 27 | extensions = nil 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /package/debian/postrm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ -d /run/systemd/system ]; then 5 | systemctl --system daemon-reload >/dev/null || true 6 | fi 7 | 8 | if [ "$1" = "remove" ]; then 9 | if [ -x "/usr/bin/deb-systemd-helper" ]; then 10 | deb-systemd-helper mask frankenphp.service >/dev/null || true 11 | fi 12 | fi 13 | 14 | if [ "$1" = "purge" ]; then 15 | if [ -x "/usr/bin/deb-systemd-helper" ]; then 16 | deb-systemd-helper purge frankenphp.service >/dev/null || true 17 | deb-systemd-helper unmask frankenphp.service >/dev/null || true 18 | fi 19 | rm -rf /var/lib/frankenphp /var/log/frankenphp /etc/frankenphp 20 | fi 21 | -------------------------------------------------------------------------------- /testdata/performance/flamegraph.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # install brendangregg's FlameGraph 4 | if [ ! -d "/usr/local/src/flamegraph" ]; then 5 | mkdir /usr/local/src/flamegraph && 6 | cd /usr/local/src/flamegraph && 7 | git clone https://github.com/brendangregg/FlameGraph.git 8 | fi 9 | 10 | # let the test warm up 11 | sleep 10 12 | 13 | # run a 30 second profile on the Caddy admin port 14 | cd /usr/local/src/flamegraph/FlameGraph && 15 | go tool pprof -raw -output=cpu.txt 'http://localhost:2019/debug/pprof/profile?seconds=30' && 16 | ./stackcollapse-go.pl cpu.txt | ./flamegraph.pl >/go/src/app/testdata/performance/flamegraph.svg 17 | -------------------------------------------------------------------------------- /package/debian/frankenphp.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=FrankenPHP 3 | Documentation=https://frankenphp.dev/docs/ 4 | After=network.target network-online.target 5 | Requires=network-online.target 6 | 7 | [Service] 8 | Type=notify 9 | User=frankenphp 10 | Group=frankenphp 11 | ExecStart=/usr/bin/frankenphp run --environ --config /etc/frankenphp/Caddyfile 12 | ExecReload=/usr/bin/frankenphp reload --config /etc/frankenphp/Caddyfile --force 13 | TimeoutStopSec=5s 14 | LimitNOFILE=1048576 15 | LimitNPROC=512 16 | PrivateTmp=true 17 | ProtectSystem=full 18 | AmbientCapabilities=CAP_NET_BIND_SERVICE 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /docs/ru/early-hints.md: -------------------------------------------------------------------------------- 1 | # Early Hints 2 | 3 | FrankenPHP изначально поддерживает [Early Hints (103 HTTP статус код)](https://developer.chrome.com/blog/early-hints/). 4 | Использование Early Hints может улучшить время загрузки ваших веб-страниц на 30%. 5 | 6 | ```php 7 | ; rel=preload; as=style'); 10 | headers_send(103); 11 | 12 | // ваши медленные алгоритмы и SQL-запросы 🤪 13 | 14 | echo <<<'HTML' 15 | 16 | Hello FrankenPHP 17 | 18 | HTML; 19 | ``` 20 | 21 | Early Hints поддерживается как в обычном, так и в [worker режиме](worker.md). 22 | -------------------------------------------------------------------------------- /docs/tr/early-hints.md: -------------------------------------------------------------------------------- 1 | # Early Hints 2 | 3 | FrankenPHP [103 Early Hints durum kodunu](https://developer.chrome.com/blog/early-hints/) yerel olarak destekler. 4 | Early Hints kullanmak web sayfalarınızın yüklenme süresini %30 oranında artırabilir. 5 | 6 | ```php 7 | ; rel=preload; as=style'); 10 | headers_send(103); 11 | 12 | // yavaş algoritmalarınız ve SQL sorgularınız 🤪 13 | 14 | echo <<<'HTML' 15 | 16 | Hello FrankenPHP 17 | 18 | HTML; 19 | ``` 20 | 21 | Early Hints hem normal hem de [worker](worker.md) modları tarafından desteklenir. 22 | -------------------------------------------------------------------------------- /docs/ja/mercure.md: -------------------------------------------------------------------------------- 1 | # リアルタイム 2 | 3 | FrankenPHPには組み込みの[Mercure](https://mercure.rocks)ハブが付属しています! 4 | Mercureを使用すると、接続されているすべてのデバイスにリアルタイムイベントをプッシュでき、各デバイスは即座にJavaScriptイベントを受信します。 5 | 6 | JSライブラリやSDKは必要ありません! 7 | 8 | ![Mercure](mercure-hub.png) 9 | 10 | Mercureハブを有効にするには、[Mercureのサイト](https://mercure.rocks/docs/hub/config)で説明されているように`Caddyfile`を更新してください。 11 | 12 | Mercureハブのパスは`/.well-known/mercure`です。 13 | FrankenPHPをDocker内で実行している場合、完全な送信URLは`http://php/.well-known/mercure`のようになります。ここでの`php`はFrankenPHPを実行するコンテナの名前です。 14 | 15 | コードからMercureの更新をプッシュするには、[Symfony Mercure Component](https://symfony.com/components/Mercure)をお勧めします。なお、Symfonyのフルスタックフレームワークは必要ありません。 16 | -------------------------------------------------------------------------------- /docs/fr/early-hints.md: -------------------------------------------------------------------------------- 1 | # Early Hints 2 | 3 | FrankenPHP prend nativement en charge le code de statut [103 Early Hints](https://developer.chrome.com/blog/early-hints/). 4 | L'utilisation des Early Hints peut améliorer le temps de chargement de vos pages web de 30 %. 5 | 6 | ```php 7 | ; rel=preload; as=style'); 10 | headers_send(103); 11 | 12 | // vos algorithmes lents et requêtes SQL 🤪 13 | 14 | echo <<<'HTML' 15 | 16 | Hello FrankenPHP 17 | 18 | HTML; 19 | ``` 20 | 21 | Les Early Hints sont pris en charge à la fois par les modes "standard" et [worker](worker.md). 22 | -------------------------------------------------------------------------------- /docs/pt-br/early-hints.md: -------------------------------------------------------------------------------- 1 | # Early Hints 2 | 3 | O FrankenPHP suporta nativamente o 4 | [código de status 103 Early Hints](https://developer.chrome.com/blog/early-hints/). 5 | Usar Early Hints pode melhorar o tempo de carregamento das suas páginas web em 6 | 30%. 7 | 8 | ```php 9 | ; rel=preload; as=style'); 12 | headers_send(103); 13 | 14 | // seus algoritmos e consultas SQL lentos 🤪 15 | 16 | echo <<<'HTML' 17 | 18 | Olá FrankenPHP 19 | 20 | HTML; 21 | ``` 22 | 23 | As Early Hints são suportadas tanto pelo modo normal quanto pelo modo 24 | [worker](worker.md). 25 | -------------------------------------------------------------------------------- /docs/tr/mercure.md: -------------------------------------------------------------------------------- 1 | # Gerçek Zamanlı 2 | 3 | FrankenPHP yerleşik bir [Mercure](https://mercure.rocks) hub ile birlikte gelir! 4 | Mercure, olayları tüm bağlı cihazlara gerçek zamanlı olarak göndermeye olanak tanır: anında bir JavaScript olayı alırlar. 5 | 6 | JS kütüphanesi veya SDK gerekmez! 7 | 8 | ![Mercure](../mercure-hub.png) 9 | 10 | Mercure hub'ını etkinleştirmek için [Mercure'ün sitesinde](https://mercure.rocks/docs/hub/config) açıklandığı gibi `Caddyfile`'ı güncelleyin. 11 | 12 | Mercure güncellemelerini kodunuzdan göndermek için [Symfony Mercure Bileşenini](https://symfony.com/components/Mercure) öneririz (kullanmak için Symfony tam yığın çerçevesine ihtiyacınız yoktur). 13 | -------------------------------------------------------------------------------- /package/rhel/frankenphp.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=FrankenPHP server 3 | After=network.target 4 | 5 | [Service] 6 | Type=notify 7 | User=frankenphp 8 | Group=frankenphp 9 | ExecStartPre=/usr/bin/frankenphp validate --config /etc/frankenphp/Caddyfile 10 | ExecStart=/usr/bin/frankenphp run --environ --config /etc/frankenphp/Caddyfile 11 | ExecReload=/usr/bin/frankenphp reload --config /etc/frankenphp/Caddyfile 12 | WorkingDirectory=/var/lib/frankenphp 13 | TimeoutStopSec=5s 14 | LimitNOFILE=1048576 15 | LimitNPROC=512 16 | PrivateTmp=true 17 | ProtectHome=true 18 | ProtectSystem=full 19 | AmbientCapabilities=CAP_NET_BIND_SERVICE 20 | 21 | [Install] 22 | WantedBy=multi-user.target 23 | -------------------------------------------------------------------------------- /docs/cn/classic.md: -------------------------------------------------------------------------------- 1 | # 使用经典模式 2 | 3 | 在没有任何额外配置的情况下,FrankenPHP 以经典模式运行。在此模式下,FrankenPHP 的功能类似于传统的 PHP 服务器,直接提供 PHP 文件服务。这使其成为 PHP-FPM 或 Apache with mod_php 的无缝替代品。 4 | 5 | 与 Caddy 类似,FrankenPHP 接受无限数量的连接,并使用[固定数量的线程](config.md#caddyfile-配置)来为它们提供服务。接受和排队的连接数量仅受可用系统资源的限制。 6 | PHP 线程池使用在启动时初始化的固定数量的线程运行,类似于 PHP-FPM 的静态模式。也可以让线程在[运行时自动扩展](performance.md#max_threads),类似于 PHP-FPM 的动态模式。 7 | 8 | 排队的连接将无限期等待,直到有 PHP 线程可以为它们提供服务。为了避免这种情况,你可以在 FrankenPHP 的全局配置中使用 max_wait_time [配置](config.md#caddyfile-配置)来限制请求可以等待空闲的 PHP 线程的时间,超时后将被拒绝。 9 | 此外,你还可以在 Caddy 中设置合理的[写超时](https://caddyserver.com/docs/caddyfile/options#timeouts)。 10 | 11 | 每个 Caddy 实例只会启动一个 FrankenPHP 线程池,该线程池将在所有 `php_server` 块之间共享。 12 | -------------------------------------------------------------------------------- /docs/ru/mercure.md: -------------------------------------------------------------------------------- 1 | # Real-time режим 2 | 3 | FrankenPHP поставляется с встроенным хабом [Mercure](https://mercure.rocks)! 4 | Mercure позволяет отправлять события в режиме реального времени на все подключённые устройства: они мгновенно получат JavaScript-событие. 5 | 6 | Не требуются JS-библиотеки или SDK! 7 | 8 | ![Mercure](../mercure-hub.png) 9 | 10 | Чтобы включить хаб Mercure, обновите `Caddyfile` в соответствии с инструкциями [на сайте Mercure](https://mercure.rocks/docs/hub/config). 11 | 12 | Для отправки обновлений Mercure из вашего кода мы рекомендуем использовать [Symfony Mercure Component](https://symfony.com/components/Mercure) (для его использования не требуется полный стек Symfony). 13 | -------------------------------------------------------------------------------- /testdata/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | debug 3 | frankenphp { 4 | #worker ./index.php 5 | } 6 | } 7 | 8 | http:// { 9 | log 10 | route { 11 | root . 12 | # Add trailing slash for directory requests 13 | @canonicalPath { 14 | file {path}/index.php 15 | not path */ 16 | } 17 | redir @canonicalPath {path}/ 308 18 | 19 | # If the requested file does not exist, try index files 20 | @indexFiles file { 21 | try_files {path} {path}/index.php index.php 22 | split_path .php 23 | } 24 | rewrite @indexFiles {http.matchers.file.relative} 25 | 26 | encode zstd br gzip 27 | 28 | # FrankenPHP! 29 | @phpFiles path *.php 30 | php @phpFiles 31 | file_server 32 | 33 | respond 404 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/fr/mercure.md: -------------------------------------------------------------------------------- 1 | # Temps Réel 2 | 3 | FrankenPHP est livré avec un hub [Mercure](https://mercure.rocks) intégré. 4 | Mercure permet de pousser des événements en temps réel vers tous les appareils connectés : ils recevront un événement JavaScript instantanément. 5 | 6 | Aucune bibliothèque JS ou SDK requis ! 7 | 8 | ![Mercure](../mercure-hub.png) 9 | 10 | Pour activer le hub Mercure, mettez à jour le `Caddyfile` comme décrit [sur le site de Mercure](https://mercure.rocks/docs/hub/config). 11 | 12 | Pour pousser des mises à jour Mercure depuis votre code, nous recommandons le [Composant Mercure de Symfony](https://symfony.com/components/Mercure) (vous n'avez pas besoin du framework full stack Symfony pour l'utiliser). 13 | -------------------------------------------------------------------------------- /testdata/performance/performance-testing.md: -------------------------------------------------------------------------------- 1 | # Running Load tests 2 | 3 | To run load tests with k6 you need to have Docker and Bash installed. 4 | Go the root of this repository and run: 5 | 6 | ```sh 7 | bash testdata/performance/perf-test.sh 8 | ``` 9 | 10 | This will build the `frankenphp-dev` Docker image and run it under the name 'load-test-container' 11 | in the background. Additionally, it will run the `grafana/k6` container, and you'll be able to choose 12 | the load test you want to run. A `flamegraph.svg` will be created in the `testdata/performance` directory. 13 | 14 | If the load test has stopped prematurely, you might have to remove the container manually: 15 | 16 | ```sh 17 | docker stop load-test-container 18 | docker rm load-test-container 19 | ``` 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | description: Suggest an idea for this project 4 | labels: [enhancement] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Describe your feature request 10 | value: | 11 | **Is your feature request related to a problem? Please describe.** 12 | A clear and concise description of what the problem is. 13 | Ex. I'm always frustrated when [...] 14 | 15 | **Describe the solution you'd like** 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | A clear and concise description of any alternative solutions 20 | or features you've considered. 21 | -------------------------------------------------------------------------------- /internal/extgen/templates/README.md.tpl: -------------------------------------------------------------------------------- 1 | # {{.BaseName}} Extension 2 | 3 | Auto-generated PHP extension from Go code. 4 | 5 | {{if .Functions}}## Functions 6 | 7 | {{range .Functions}}### {{.Name}} 8 | 9 | ```php 10 | {{.Signature}} 11 | ``` 12 | 13 | {{if .Params}}**Parameters:** 14 | 15 | {{range .Params}}- `{{.Name}}` ({{.PhpType}}){{if .IsNullable}} (nullable){{end}}{{if .HasDefault}} (default: {{.DefaultValue}}){{end}} 16 | {{end}} 17 | {{end}}**Returns:** {{.ReturnType}}{{if .IsReturnNullable}} (nullable){{end}} 18 | 19 | {{end}}{{end}}{{if .Classes}}## Classes 20 | 21 | {{range .Classes}}### {{.Name}} 22 | 23 | {{if .Properties}}**Properties:** 24 | 25 | {{range .Properties}}- `{{.Name}}`: {{.PhpType}}{{if .IsNullable}} (nullable){{end}} 26 | {{end}} 27 | {{end}}{{end}}{{end}} 28 | -------------------------------------------------------------------------------- /log_test.go: -------------------------------------------------------------------------------- 1 | package frankenphp_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log/slog" 7 | "sync" 8 | "testing" 9 | ) 10 | 11 | func newTestLogger(t *testing.T) (*slog.Logger, fmt.Stringer) { 12 | t.Helper() 13 | 14 | var buf syncBuffer 15 | 16 | return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), &buf 17 | } 18 | 19 | // SyncBuffer is a thread-safe buffer for capturing logs in tests. 20 | type syncBuffer struct { 21 | b bytes.Buffer 22 | mu sync.RWMutex 23 | } 24 | 25 | func (s *syncBuffer) Write(p []byte) (n int, err error) { 26 | s.mu.Lock() 27 | defer s.mu.Unlock() 28 | 29 | return s.b.Write(p) 30 | } 31 | 32 | func (s *syncBuffer) String() string { 33 | s.mu.RLock() 34 | defer s.mu.RUnlock() 35 | 36 | return s.b.String() 37 | } 38 | -------------------------------------------------------------------------------- /testdata/dd.php: -------------------------------------------------------------------------------- 1 | message = $message; 13 | } 14 | 15 | public function __destruct() 16 | { 17 | if (isset($this->message)) { 18 | echo $this->message; 19 | } 20 | } 21 | } 22 | 23 | $dumper = new Dumper(); 24 | 25 | while (frankenphp_handle_request(function () use ($dumper) { 26 | $dumper->dump($_GET['output'] ?? ''); 27 | exit(1); 28 | })) { 29 | // keep handling requests 30 | } 31 | 32 | echo "we should never reach here\n"; 33 | -------------------------------------------------------------------------------- /docs/ja/classic.md: -------------------------------------------------------------------------------- 1 | # クラシックモードの使用 2 | 3 | 追加の設定を行わなくても、FrankenPHPはクラシックモードで動作します。このモードでは、FrankenPHPは従来のPHPサーバーのように機能し、PHPファイルを直接提供します。これにより、PHP-FPMやmod_phpを使ったApacheの置き換えとしてシームレスに利用できます。 4 | 5 | Caddyと同様に、FrankenPHPは無制限の接続を受け付け、[固定数のスレッド](config.md#caddyfile-config)でそれらを処理します。受け入れられキューに入れられる接続の数は、利用可能なシステムリソースによってのみ制限されます。 6 | PHPスレッドプールは、起動時に初期化された固定数のスレッドで動作し、これはPHP-FPMの静的モードに相当します。また、PHP-FPMの動的モードと同様に、[実行時にスレッドを自動的にスケール](performance.md#max_threads)させることも可能です。 7 | 8 | キューに入った接続は、PHPスレッドが空くまで無期限に待機します。これを避けるために、FrankenPHP のグローバル設定内の `max_wait_time` [設定](config.md#caddyfile-config)を使って、リクエストが空きスレッドを待てる最大時間を制限し、それを超えるとリクエストが拒否されるようにできます。 9 | 加えて、[Caddy側で適切な書き込みタイムアウト](https://caddyserver.com/docs/caddyfile/options#timeouts)を設定することも可能です。 10 | 11 | 各Caddyインスタンスは、1つのFrankenPHPスレッドプールのみを起動し、すべての`php_server`ブロック間でこのプールを共有します。 12 | -------------------------------------------------------------------------------- /internal/extgen/parser.go: -------------------------------------------------------------------------------- 1 | package extgen 2 | 3 | type SourceParser struct{} 4 | 5 | // EXPERIMENTAL 6 | func (p *SourceParser) ParseFunctions(filename string) ([]phpFunction, error) { 7 | functionParser := &FuncParser{} 8 | return functionParser.parse(filename) 9 | } 10 | 11 | // EXPERIMENTAL 12 | func (p *SourceParser) ParseClasses(filename string) ([]phpClass, error) { 13 | classParser := classParser{} 14 | return classParser.parse(filename) 15 | } 16 | 17 | // EXPERIMENTAL 18 | func (p *SourceParser) ParseConstants(filename string) ([]phpConstant, error) { 19 | constantParser := &ConstantParser{} 20 | return constantParser.parse(filename) 21 | } 22 | 23 | // EXPERIMENTAL 24 | func (p *SourceParser) ParseNamespace(filename string) (string, error) { 25 | namespaceParser := NamespaceParser{} 26 | return namespaceParser.parse(filename) 27 | } 28 | -------------------------------------------------------------------------------- /internal/phpheaders/phpheaders_test.go: -------------------------------------------------------------------------------- 1 | package phpheaders 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestAllCommonHeadersAreCorrect(t *testing.T) { 11 | fakeRequest := httptest.NewRequest("GET", "http://localhost", nil) 12 | 13 | for header, phpHeader := range CommonRequestHeaders { 14 | // verify that common and uncommon headers return the same result 15 | expectedPHPHeader := GetUnCommonHeader(t.Context(), header) 16 | assert.Equal(t, phpHeader+"\x00", expectedPHPHeader, "header is not well formed: "+phpHeader) 17 | 18 | // net/http will capitalize lowercase headers, verify that headers are capitalized 19 | fakeRequest.Header.Add(header, "foo") 20 | assert.Contains(t, fakeRequest.Header, header, "header is not correctly capitalized: "+header) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/fastabs/filepath_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | 3 | package fastabs 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | ) 9 | var ( 10 | wd string 11 | wderr error 12 | ) 13 | 14 | func init() { 15 | wd, wderr = os.Getwd() 16 | 17 | if wderr != nil { 18 | return 19 | } 20 | 21 | canonicalWD, err := filepath.EvalSymlinks(wd) 22 | if err != nil { 23 | wd = canonicalWD 24 | } 25 | } 26 | 27 | // FastAbs is an optimized version of filepath.Abs for Unix systems, 28 | // since we don't expect the working directory to ever change once 29 | // Caddy is running. Avoid the os.Getwd syscall overhead. 30 | func FastAbs(path string) (string, error) { 31 | if filepath.IsAbs(path) { 32 | return filepath.Clean(path), nil 33 | } 34 | 35 | if wderr != nil { 36 | return "", wderr 37 | } 38 | 39 | return filepath.Join(wd, path), nil 40 | } 41 | 42 | -------------------------------------------------------------------------------- /caddy/mercure.go: -------------------------------------------------------------------------------- 1 | //go:build !nomercure 2 | 3 | package caddy 4 | 5 | import ( 6 | "github.com/caddyserver/caddy/v2" 7 | "github.com/dunglas/frankenphp" 8 | "github.com/dunglas/mercure" 9 | mercureCaddy "github.com/dunglas/mercure/caddy" 10 | ) 11 | 12 | func init() { 13 | mercureCaddy.AllowNoPublish = true 14 | } 15 | 16 | type mercureContext struct { 17 | mercureHub *mercure.Hub 18 | } 19 | 20 | func (f *FrankenPHPModule) assignMercureHub(ctx caddy.Context) { 21 | if f.mercureHub = mercureCaddy.FindHub(ctx.Modules()); f.mercureHub == nil { 22 | return 23 | } 24 | 25 | opt := frankenphp.WithMercureHub(f.mercureHub) 26 | f.mercureHubRequestOption = &opt 27 | 28 | for i, wc := range f.Workers { 29 | wc.mercureHub = f.mercureHub 30 | wc.options = append(wc.options, frankenphp.WithWorkerMercureHub(wc.mercureHub)) 31 | 32 | f.Workers[i] = wc 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /testdata/performance/computation.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | 3 | /** 4 | * Simulate an application that does very little IO, but a lot of computation 5 | */ 6 | export const options = { 7 | stages: [ 8 | { duration: "20s", target: 80 }, 9 | { duration: "20s", target: 150 }, 10 | { duration: "5s", target: 0 }, 11 | ], 12 | thresholds: { 13 | http_req_failed: ["rate<0.01"], 14 | }, 15 | }; 16 | 17 | /* global __ENV */ 18 | export default function () { 19 | // do 1-1,000,000 work units 20 | const work = Math.ceil(Math.random() * 1_000_000); 21 | // output 1-500 units 22 | const output = Math.ceil(Math.random() * 500); 23 | // simulate 0-2ms latency 24 | const latency = Math.floor(Math.random() * 3); 25 | 26 | http.get( 27 | http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`, 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /caddy/watcher_test.go: -------------------------------------------------------------------------------- 1 | //go:build !nowatcher 2 | 3 | package caddy_test 4 | 5 | import ( 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/caddyserver/caddy/v2/caddytest" 10 | ) 11 | 12 | func TestWorkerWithInactiveWatcher(t *testing.T) { 13 | tester := caddytest.NewTester(t) 14 | tester.InitServer(` 15 | { 16 | skip_install_trust 17 | admin localhost:2999 18 | http_port `+testPort+` 19 | 20 | frankenphp { 21 | worker { 22 | file ../testdata/worker-with-counter.php 23 | num 1 24 | watch ./**/*.php 25 | } 26 | } 27 | } 28 | 29 | localhost:`+testPort+` { 30 | root ../testdata 31 | rewrite worker-with-counter.php 32 | php 33 | } 34 | `, "caddyfile") 35 | 36 | tester.AssertGetResponse("http://localhost:"+testPort, http.StatusOK, "requests:1") 37 | tester.AssertGetResponse("http://localhost:"+testPort, http.StatusOK, "requests:2") 38 | } 39 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: gomod 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | commit-message: 9 | prefix: chore 10 | groups: 11 | go-modules: 12 | patterns: 13 | - "*" 14 | cooldown: 15 | default-days: 7 16 | - package-ecosystem: gomod 17 | directory: /caddy 18 | schedule: 19 | interval: weekly 20 | commit-message: 21 | prefix: chore(caddy) 22 | groups: 23 | go-modules: 24 | patterns: 25 | - "*" 26 | cooldown: 27 | default-days: 7 28 | - package-ecosystem: github-actions 29 | directory: / 30 | schedule: 31 | interval: weekly 32 | commit-message: 33 | prefix: ci 34 | groups: 35 | github-actions: 36 | patterns: 37 | - "*" 38 | cooldown: 39 | default-days: 7 40 | -------------------------------------------------------------------------------- /testdata/performance/hanging-requests.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | 3 | /** 4 | * It is not uncommon for external services to hang for a long time. 5 | * Make sure the server is resilient in such cases and doesn't hang as well. 6 | */ 7 | export const options = { 8 | stages: [ 9 | { duration: "20s", target: 100 }, 10 | { duration: "20s", target: 500 }, 11 | { duration: "20s", target: 0 }, 12 | ], 13 | thresholds: { 14 | http_req_failed: ["rate<0.01"], 15 | }, 16 | }; 17 | 18 | /* global __ENV */ 19 | export default function () { 20 | // 2% chance for a request that hangs for 15s 21 | if (Math.random() < 0.02) { 22 | http.get( 23 | `${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=15000&work=10000&output=100`, 24 | ); 25 | return; 26 | } 27 | 28 | // a regular request 29 | http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=10000&output=100`); 30 | } 31 | -------------------------------------------------------------------------------- /testdata/integration/basic_function.go: -------------------------------------------------------------------------------- 1 | package testintegration 2 | 3 | // #include 4 | import "C" 5 | import ( 6 | "strings" 7 | "unsafe" 8 | 9 | "github.com/dunglas/frankenphp" 10 | ) 11 | 12 | // export_php:function test_uppercase(string $str): string 13 | func test_uppercase(s *C.zend_string) unsafe.Pointer { 14 | str := frankenphp.GoString(unsafe.Pointer(s)) 15 | upper := strings.ToUpper(str) 16 | return frankenphp.PHPString(upper, false) 17 | } 18 | 19 | // export_php:function test_add_numbers(int $a, int $b): int 20 | func test_add_numbers(a int64, b int64) int64 { 21 | return a + b 22 | } 23 | 24 | // export_php:function test_multiply(float $a, float $b): float 25 | func test_multiply(a float64, b float64) float64 { 26 | return a * b 27 | } 28 | 29 | // export_php:function test_is_enabled(bool $flag): bool 30 | func test_is_enabled(flag bool) bool { 31 | return !flag 32 | } 33 | -------------------------------------------------------------------------------- /testdata/sleep.php: -------------------------------------------------------------------------------- 1 | 0) { 21 | usleep($sleep * 1000); 22 | } 23 | 24 | // simulate output 25 | for ($k = 0; $k < $output; $k++) { 26 | echo "slept for $sleep ms and worked for $work iterations"; 27 | } 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /docs/cn/github-actions.md: -------------------------------------------------------------------------------- 1 | # 使用 GitHub Actions 2 | 3 | 此存储库构建 Docker 镜像并将其部署到 [Docker Hub](https://hub.docker.com/r/dunglas/frankenphp) 上 4 | 每个批准的拉取请求或设置后在你自己的分支上。 5 | 6 | ## 设置 GitHub Actions 7 | 8 | 在存储库设置中的 `secrets` 下,添加以下字段: 9 | 10 | - `REGISTRY_LOGIN_SERVER`: 要使用的 Docker registry(如 `docker.io`)。 11 | - `REGISTRY_USERNAME`: 用于登录 registry 的用户名(如 `dunglas`)。 12 | - `REGISTRY_PASSWORD`: 用于登录 registry 的密码(如 `access key`)。 13 | - `IMAGE_NAME`: 镜像的名称(如 `dunglas/frankenphp`)。 14 | 15 | ## 构建和推送镜像 16 | 17 | 1. 创建 Pull Request 或推送到你的 Fork 分支。 18 | 2. GitHub Actions 将生成镜像并运行每项测试。 19 | 3. 如果生成成功,则将使用 `pr-x` 推送 registry,其中 `x` 是 PR 编号,作为标记将镜像推送到注册表。 20 | 21 | ## 部署镜像 22 | 23 | 1. 合并 Pull Request 后,GitHub Actions 将再次运行测试并生成新镜像。 24 | 2. 如果构建成功,则 Docker 注册表中的 `main` tag 将更新。 25 | 26 | ## 发布 27 | 28 | 1. 在项目仓库中创建新 Tag。 29 | 2. GitHub Actions 将生成镜像并运行每项测试。 30 | 3. 如果构建成功,镜像将使用标记名称作为标记推送到 registry(例如,将创建 `v1.2.3` 和 `v1.2`)。 31 | 4. `latest` 标签也将更新。 32 | -------------------------------------------------------------------------------- /testdata/server-all-vars-ordered.php: -------------------------------------------------------------------------------- 1 | \n"; 4 | foreach ([ 5 | 'CONTENT_LENGTH', 6 | 'HTTP_CONTENT_LENGTH', 7 | 'CONTENT_TYPE', 8 | 'HTTP_CONTENT_TYPE', 9 | 'HTTP_SPECIAL_CHARS', 10 | 'DOCUMENT_ROOT', 11 | 'DOCUMENT_URI', 12 | 'GATEWAY_INTERFACE', 13 | 'HTTP_HOST', 14 | 'HTTPS', 15 | 'PATH_INFO', 16 | 'DOCUMENT_ROOT', 17 | 'REMOTE_ADDR', 18 | 'PHP_SELF', 19 | 'REMOTE_HOST', 20 | 'REQUEST_SCHEME', 21 | 'SCRIPT_FILENAME', 22 | 'SCRIPT_NAME', 23 | 'SERVER_NAME', 24 | 'SERVER_PORT', 25 | 'SERVER_PROTOCOL', 26 | 'SERVER_SOFTWARE', 27 | 'SSL_PROTOCOL', 28 | 'AUTH_TYPE', 29 | 'REMOTE_IDENT', 30 | 'PATH_TRANSLATED', 31 | 'QUERY_STRING', 32 | 'REMOTE_USER', 33 | 'REQUEST_METHOD', 34 | 'REQUEST_URI', 35 | 'HTTP_X_EMPTY_HEADER', 36 | ] as $name) { 37 | echo "$name:" . $_SERVER[$name] . "\n"; 38 | } 39 | echo "\n"; 40 | -------------------------------------------------------------------------------- /testdata/performance/api.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | 3 | /** 4 | * Many applications communicate with external APIs or microservices. 5 | * Latencies tend to be much higher than with databases in these cases. 6 | * We'll consider 10ms-150ms 7 | */ 8 | export const options = { 9 | stages: [ 10 | { duration: "20s", target: 150 }, 11 | { duration: "20s", target: 1000 }, 12 | { duration: "10s", target: 0 }, 13 | ], 14 | thresholds: { 15 | http_req_failed: ["rate<0.01"], 16 | }, 17 | }; 18 | 19 | /* global __ENV */ 20 | export default function () { 21 | // 10-150ms latency 22 | const latency = Math.floor(Math.random() * 141) + 10; 23 | // 1-30000 work units 24 | const work = Math.ceil(Math.random() * 30000); 25 | // 1-40 output units 26 | const output = Math.ceil(Math.random() * 40); 27 | 28 | http.get( 29 | http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`, 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /docs/pt-br/mercure.md: -------------------------------------------------------------------------------- 1 | # Tempo real 2 | 3 | O FrankenPHP vem com um hub [Mercure](https://mercure.rocks) integrado! 4 | O Mercure permite que você envie eventos em tempo real para todos os 5 | dispositivos conectados: eles receberão um evento JavaScript instantaneamente. 6 | 7 | Não é necessária nenhuma biblioteca JS ou SDK! 8 | 9 | ![Mercure](mercure-hub.png) 10 | 11 | Para habilitar o hub Mercure, atualize o `Caddyfile` conforme descrito 12 | [no site do Mercure](https://mercure.rocks/docs/hub/config). 13 | 14 | O caminho do hub Mercure é `/.well-known/mercure`. 15 | Ao executar o FrankenPHP dentro do Docker, a URL de envio completa seria 16 | `http://php/.well-known/mercure` (com `php` sendo o nome do contêiner que 17 | executa o FrankenPHP). 18 | 19 | Para enviar atualizações do Mercure a partir do seu código, recomendamos o 20 | [Componente Symfony Mercure](https://symfony.com/components/Mercure) (você não 21 | precisa do framework full-stack do Symfony para usá-lo). 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest version is supported. 6 | Please ensure that you're always using the latest release. 7 | 8 | Binaries and Docker images are rebuilt nightly using the latest versions of dependencies. 9 | 10 | ## Reporting a Vulnerability 11 | 12 | If you believe you have discovered a security issue directly affecting FrankenPHP, 13 | please do **NOT** report it publicly. 14 | 15 | Please write a detailed vulnerability report and send it [through GitHub](https://github.com/php/frankenphp/security/advisories/new) or to [kevin+frankenphp-security@dunglas.dev](mailto:kevin+frankenphp-security@dunglas.dev?subject=Security%20issue%20affecting%20FrankenPHP). 16 | 17 | Only vulnerabilities directly affecting FrankenPHP should be reported to this project. 18 | Flaws affecting components used by FrankenPHP (PHP, Caddy, Go...) or using FrankenPHP (Laravel Octane, PHP Runtime...) should be reported to the relevant projects. 19 | -------------------------------------------------------------------------------- /docs/cn/metrics.md: -------------------------------------------------------------------------------- 1 | # 指标 2 | 3 | 当启用 [Caddy 指标](https://caddyserver.com/docs/metrics) 时,FrankenPHP 公开以下指标: 4 | 5 | - `frankenphp_total_threads`:PHP 线程的总数。 6 | - `frankenphp_busy_threads`:当前正在处理请求的 PHP 线程数(运行中的 worker 始终占用一个线程)。 7 | - `frankenphp_queue_depth`:常规排队请求的数量 8 | - `frankenphp_total_workers{worker="[worker_name]"}`:worker 的总数。 9 | - `frankenphp_busy_workers{worker="[worker_name]"}`:当前正在处理请求的 worker 数量。 10 | - `frankenphp_worker_request_time{worker="[worker_name]"}`:所有 worker 处理请求所花费的时间。 11 | - `frankenphp_worker_request_count{worker="[worker_name]"}`:所有 worker 处理的请求数量。 12 | - `frankenphp_ready_workers{worker="[worker_name]"}`:至少调用过一次 `frankenphp_handle_request` 的 worker 数量。 13 | - `frankenphp_worker_crashes{worker="[worker_name]"}`:worker 意外终止的次数。 14 | - `frankenphp_worker_restarts{worker="[worker_name]"}`:worker 被故意重启的次数。 15 | - `frankenphp_worker_queue_depth{worker="[worker_name]"}`:排队请求的数量。 16 | 17 | 对于 worker 指标,`[worker_name]` 占位符被 Caddyfile 中的 worker 名称替换,否则将使用 worker 文件的绝对路径。 18 | -------------------------------------------------------------------------------- /docs/ja/github-actions.md: -------------------------------------------------------------------------------- 1 | # GitHub Actionsの使用 2 | 3 | このリポジトリでは、承認されたプルリクエストごと、またはセットアップ後のあなた自身のフォークで、 4 | Dockerイメージをビルドして[Docker Hub](https://hub.docker.com/r/dunglas/frankenphp)にデプロイします。 5 | 6 | ## GitHub Actionsのセットアップ 7 | 8 | リポジトリ設定のシークレットで、以下のシークレットを追加してください: 9 | 10 | - `REGISTRY_LOGIN_SERVER`: 使用するDockerレジストリ(例:`docker.io`) 11 | - `REGISTRY_USERNAME`: レジストリログイン用のユーザー名(例:`dunglas`) 12 | - `REGISTRY_PASSWORD`: レジストリログイン用のパスワード(例:アクセスキー) 13 | - `IMAGE_NAME`: イメージの名前(例:`dunglas/frankenphp`) 14 | 15 | ## イメージのビルドとプッシュ 16 | 17 | 1. プルリクエストを作成するか、フォークにプッシュします 18 | 2. GitHub Actionsがイメージをビルドし、テストを実行します 19 | 3. ビルドが成功した場合、イメージは`pr-x`(`x`はPR番号)をタグとしてレジストリにプッシュされます 20 | 21 | ## イメージのデプロイ 22 | 23 | 1. プルリクエストがマージされると、GitHub Actionsが再度テストを実行し、新しいイメージをビルドします 24 | 2. ビルドが成功した場合、Dockerレジストリの`main`タグが更新されます 25 | 26 | ## リリース 27 | 28 | 1. リポジトリで新しいタグを作成します 29 | 2. GitHub Actionsがイメージをビルドし、テストを実行します 30 | 3. ビルドが成功した場合、イメージはタグ名をタグとしてレジストリにプッシュされます(例:`v1.2.3`と`v1.2`が作成されます) 31 | 4. `latest`タグも更新されます 32 | -------------------------------------------------------------------------------- /internal/extgen/templates/stub.php.tpl: -------------------------------------------------------------------------------- 1 | 2 | CONTENT_LENGTH:7 3 | HTTP_CONTENT_LENGTH:7 4 | CONTENT_TYPE:application/x-www-form-urlencoded 5 | HTTP_CONTENT_TYPE:application/x-www-form-urlencoded 6 | HTTP_SPECIAL_CHARS:<%00> 7 | DOCUMENT_ROOT:{documentRoot} 8 | DOCUMENT_URI:/server-all-vars-ordered.php 9 | GATEWAY_INTERFACE:CGI/1.1 10 | HTTP_HOST:localhost:{testPort} 11 | HTTPS: 12 | PATH_INFO:/path 13 | DOCUMENT_ROOT:{documentRoot} 14 | REMOTE_ADDR:127.0.0.1 15 | PHP_SELF:/server-all-vars-ordered.php/path 16 | REMOTE_HOST:127.0.0.1 17 | REQUEST_SCHEME:http 18 | SCRIPT_FILENAME:{documentRoot}/server-all-vars-ordered.php 19 | SCRIPT_NAME:/server-all-vars-ordered.php 20 | SERVER_NAME:localhost 21 | SERVER_PORT:{testPort} 22 | SERVER_PROTOCOL:HTTP/1.1 23 | SERVER_SOFTWARE:FrankenPHP 24 | SSL_PROTOCOL: 25 | AUTH_TYPE: 26 | REMOTE_IDENT: 27 | PATH_TRANSLATED:{documentRoot}/path 28 | QUERY_STRING:specialChars=%3E\x00%00 29 | REMOTE_USER:user 30 | REQUEST_METHOD:POST 31 | REQUEST_URI:/original-path?specialChars=%3E\x00%00 32 | HTTP_X_EMPTY_HEADER: 33 | 34 | -------------------------------------------------------------------------------- /docs/ja/metrics.md: -------------------------------------------------------------------------------- 1 | # メトリクス 2 | 3 | [Caddyのメトリクス](https://caddyserver.com/docs/metrics)が有効になっていると、FrankenPHPは以下のメトリクスを公開します: 4 | 5 | - `frankenphp_total_threads`: PHPスレッドの総数 6 | - `frankenphp_busy_threads`: 現在リクエストを処理中のPHPスレッド数。なお、実行中のワーカーは常にスレッドを消費します 7 | - `frankenphp_queue_depth`: 通常のキューに入っているリクエストの数 8 | - `frankenphp_total_workers{worker="[worker_name]"}`: ワーカーの総数 9 | - `frankenphp_busy_workers{worker="[worker_name]"}`: 現在リクエストを処理中のワーカーの数 10 | - `frankenphp_worker_request_time{worker="[worker_name]"}`: すべてのワーカーがリクエスト処理に費やした時間 11 | - `frankenphp_worker_request_count{worker="[worker_name]"}`: すべてのワーカーが処理したリクエスト数 12 | - `frankenphp_ready_workers{worker="[worker_name]"}`: 少なくとも一度は `frankenphp_handle_request` を呼び出したワーカーの数 13 | - `frankenphp_worker_crashes{worker="[worker_name]"}`: ワーカーが予期せず終了した回数 14 | - `frankenphp_worker_restarts{worker="[worker_name]"}`: ワーカーが意図的に再起動された回数 15 | - `frankenphp_worker_queue_depth{worker="[worker_name]"}`: キューに入っているリクエストの数 16 | 17 | ワーカーメトリクスの`[worker_name]`プレースホルダーは、Caddyfileに指定されたワーカー名に置き換えられます。ワーカー名が指定されていない場合は、ワーカーファイルの絶対パスが使用されます。 18 | -------------------------------------------------------------------------------- /testdata/performance/database.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | 3 | /** 4 | * Modern databases tend to have latencies in the single-digit milliseconds. 5 | * We'll simulate 1-10ms latencies and 1-2 queries per request. 6 | */ 7 | export const options = { 8 | stages: [ 9 | { duration: "20s", target: 100 }, 10 | { duration: "30s", target: 200 }, 11 | { duration: "10s", target: 0 }, 12 | ], 13 | thresholds: { 14 | http_req_failed: ["rate<0.01"], 15 | }, 16 | }; 17 | 18 | /* global __ENV */ 19 | export default function () { 20 | // 1-10ms latency 21 | const latency = Math.floor(Math.random() * 10) + 1; 22 | // 1-2 iterations per request 23 | const iterations = Math.floor(Math.random() * 2) + 1; 24 | // 1-30000 work units per iteration 25 | const work = Math.ceil(Math.random() * 30000); 26 | // 1-40 output units 27 | const output = Math.ceil(Math.random() * 40); 28 | 29 | http.get( 30 | http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}&iterations=${iterations}`, 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /types.h: -------------------------------------------------------------------------------- 1 | #ifndef TYPES_H 2 | #define TYPES_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | zval *get_ht_packed_data(HashTable *, uint32_t index); 11 | Bucket *get_ht_bucket_data(HashTable *, uint32_t index); 12 | 13 | void *__emalloc__(size_t size); 14 | void __efree__(void *ptr); 15 | void __zend_hash_init__(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor, 16 | bool persistent); 17 | 18 | int __zend_is_callable__(zval *cb); 19 | int __call_user_function__(zval *function_name, zval *retval, 20 | uint32_t param_count, zval params[]); 21 | 22 | void __zval_null__(zval *zv); 23 | void __zval_bool__(zval *zv, bool val); 24 | void __zval_long__(zval *zv, zend_long val); 25 | void __zval_double__(zval *zv, double val); 26 | void __zval_string__(zval *zv, zend_string *str); 27 | void __zval_empty_string__(zval *zv); 28 | void __zval_arr__(zval *zv, zend_array *arr); 29 | zend_array *__zend_new_array__(uint32_t size); 30 | 31 | #endif 32 | -------------------------------------------------------------------------------- /testdata/performance/timeouts.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | 3 | /** 4 | * Databases or external resources can sometimes become unavailable for short periods of time. 5 | * Make sure the server can recover quickly from periods of unavailability. 6 | * This simulation swaps between a hanging and a working server every 10 seconds. 7 | */ 8 | export const options = { 9 | stages: [ 10 | { duration: "20s", target: 100 }, 11 | { duration: "20s", target: 500 }, 12 | { duration: "20s", target: 0 }, 13 | ], 14 | thresholds: { 15 | http_req_failed: ["rate<0.01"], 16 | }, 17 | }; 18 | 19 | /* global __ENV */ 20 | export default function () { 21 | const tenSecondInterval = Math.floor(new Date().getSeconds() / 10); 22 | const shouldHang = tenSecondInterval % 2 === 0; 23 | 24 | // every 10 seconds requests lead to a max_execution-timeout 25 | if (shouldHang) { 26 | http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=50000`); 27 | return; 28 | } 29 | 30 | // every other 10 seconds the resource is back 31 | http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=30000&output=100`); 32 | } 33 | -------------------------------------------------------------------------------- /internal/extgen/docs.go: -------------------------------------------------------------------------------- 1 | package extgen 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "path/filepath" 7 | "text/template" 8 | ) 9 | 10 | //go:embed templates/README.md.tpl 11 | var docFileContent string 12 | 13 | type DocumentationGenerator struct { 14 | generator *Generator 15 | } 16 | 17 | type DocTemplateData struct { 18 | BaseName string 19 | Functions []phpFunction 20 | Classes []phpClass 21 | } 22 | 23 | func (dg *DocumentationGenerator) generate() error { 24 | filename := filepath.Join(dg.generator.BuildDir, "README.md") 25 | content, err := dg.generateMarkdown() 26 | if err != nil { 27 | return err 28 | } 29 | 30 | return writeFile(filename, content) 31 | } 32 | 33 | func (dg *DocumentationGenerator) generateMarkdown() (string, error) { 34 | tmpl := template.Must(template.New("readme").Parse(docFileContent)) 35 | 36 | var buf bytes.Buffer 37 | if err := tmpl.Execute(&buf, DocTemplateData{ 38 | BaseName: dg.generator.BaseName, 39 | Functions: dg.generator.Functions, 40 | Classes: dg.generator.Classes, 41 | }); err != nil { 42 | return "", err 43 | } 44 | 45 | return buf.String(), nil 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2022-present Kévin Dunglas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /mercure_test.go: -------------------------------------------------------------------------------- 1 | //go:build !nomercure 2 | 3 | package frankenphp_test 4 | 5 | import ( 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/dunglas/frankenphp" 12 | "github.com/dunglas/mercure" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestMercurePublish_module(t *testing.T) { testMercurePublish(t, &testOptions{}) } 18 | func TestMercurePublish_worker(t *testing.T) { 19 | testMercurePublish(t, &testOptions{workerScript: "index.php"}) 20 | } 21 | func testMercurePublish(t *testing.T, opts *testOptions) { 22 | h, err := mercure.NewHub(t.Context(), mercure.WithTransport(mercure.NewLocalTransport(mercure.NewSubscriberList(0)))) 23 | require.NoError(t, err) 24 | 25 | opts.requestOpts = []frankenphp.RequestOption{frankenphp.WithMercureHub(h)} 26 | 27 | runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { 28 | body, _ := testGet(fmt.Sprintf("https://example.com/mercure-publish.php?i=%d", i), handler, t) 29 | assert.Contains(t, body, "update 1: ") 30 | assert.Contains(t, body, "update 2: ") 31 | }, opts) 32 | } 33 | -------------------------------------------------------------------------------- /watcher.go: -------------------------------------------------------------------------------- 1 | //go:build !nowatcher 2 | 3 | package frankenphp 4 | 5 | import ( 6 | "sync/atomic" 7 | 8 | "github.com/dunglas/frankenphp/internal/watcher" 9 | watcherGo "github.com/e-dant/watcher/watcher-go" 10 | ) 11 | 12 | type hotReloadOpt struct { 13 | hotReload []*watcher.PatternGroup 14 | } 15 | 16 | var restartWorkers atomic.Bool 17 | 18 | func initWatchers(o *opt) error { 19 | watchPatterns := make([]*watcher.PatternGroup, 0, len(o.hotReload)) 20 | 21 | for _, o := range o.workers { 22 | if len(o.watch) == 0 { 23 | continue 24 | } 25 | 26 | watcherIsEnabled = true 27 | watchPatterns = append(watchPatterns, &watcher.PatternGroup{Patterns: o.watch, Callback: func(_ []*watcherGo.Event) { 28 | restartWorkers.Store(true) 29 | }}) 30 | } 31 | 32 | if watcherIsEnabled { 33 | watchPatterns = append(watchPatterns, &watcher.PatternGroup{ 34 | Callback: func(_ []*watcherGo.Event) { 35 | if restartWorkers.Swap(false) { 36 | RestartWorkers() 37 | } 38 | }, 39 | }) 40 | } 41 | 42 | return watcher.InitWatcher(globalCtx, globalLogger, append(watchPatterns, o.hotReload...)) 43 | } 44 | 45 | func drainWatchers() { 46 | watcher.DrainWatcher() 47 | } 48 | -------------------------------------------------------------------------------- /internal/cpu/cpu_unix.go: -------------------------------------------------------------------------------- 1 | package cpu 2 | 3 | // #include 4 | import "C" 5 | import ( 6 | "runtime" 7 | "time" 8 | ) 9 | 10 | var cpuCount = runtime.GOMAXPROCS(0) 11 | 12 | // ProbeCPUs probes the CPU usage of the process 13 | // if CPUs are not busy, most threads are likely waiting for I/O, so we should scale 14 | // if CPUs are already busy we won't gain much by scaling and want to avoid the overhead of doing so 15 | func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{}) bool { 16 | var cpuStart, cpuEnd C.struct_timespec 17 | 18 | // note: clock_gettime is a POSIX function 19 | // on Windows we'd need to use QueryPerformanceCounter instead 20 | start := time.Now() 21 | C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuStart) 22 | 23 | select { 24 | case <-abort: 25 | return false 26 | case <-time.After(probeTime): 27 | } 28 | 29 | C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuEnd) 30 | elapsedTime := float64(time.Since(start).Nanoseconds()) 31 | elapsedCpuTime := float64(cpuEnd.tv_sec-cpuStart.tv_sec)*1e9 + float64(cpuEnd.tv_nsec-cpuStart.tv_nsec) 32 | cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) 33 | 34 | return cpuUsage < maxCPUUsage 35 | } 36 | -------------------------------------------------------------------------------- /internal/extgen/utils.go: -------------------------------------------------------------------------------- 1 | package extgen 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | func writeFile(filename, content string) error { 10 | return os.WriteFile(filename, []byte(content), 0644) 11 | } 12 | 13 | func readFile(filename string) (string, error) { 14 | content, err := os.ReadFile(filename) 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | return string(content), nil 20 | } 21 | 22 | // NamespacedName converts a namespace and name to a C-compatible format. 23 | // E.g., namespace "Go\Extension" and name "MyClass" become "Go_Extension_MyClass". 24 | // This symbol remains exported, so it's usable in templates. 25 | func NamespacedName(namespace, name string) string { 26 | if namespace == "" { 27 | return name 28 | } 29 | namespacePart := strings.ReplaceAll(namespace, `\`, "_") 30 | return namespacePart + "_" + name 31 | } 32 | 33 | // EXPERIMENTAL 34 | func SanitizePackageName(name string) string { 35 | sanitized := strings.ReplaceAll(name, "-", "_") 36 | sanitized = strings.ReplaceAll(sanitized, ".", "_") 37 | 38 | if len(sanitized) > 0 && !unicode.IsLetter(rune(sanitized[0])) && sanitized[0] != '_' { 39 | sanitized = "_" + sanitized 40 | } 41 | 42 | return sanitized 43 | } 44 | -------------------------------------------------------------------------------- /docs/ru/metrics.md: -------------------------------------------------------------------------------- 1 | # Метрики 2 | 3 | При включении [метрик Caddy](https://caddyserver.com/docs/metrics) FrankenPHP предоставляет следующие метрики: 4 | 5 | - `frankenphp_[worker]_total_workers`: Общее количество worker-скриптов. 6 | - `frankenphp_[worker]_busy_workers`: Количество worker-скриптов, которые в данный момент обрабатывают запрос. 7 | - `frankenphp_[worker]_worker_request_time`: Время, затраченное всеми worker-скриптами на обработку запросов. 8 | - `frankenphp_[worker]_worker_request_count`: Количество запросов, обработанных всеми worker-скриптами. 9 | - `frankenphp_[worker]_ready_workers`: Количество worker-скриптов, которые вызвали `frankenphp_handle_request` хотя бы один раз. 10 | - `frankenphp_[worker]_worker_crashes`: Количество случаев неожиданного завершения worker-скриптов. 11 | - `frankenphp_[worker]_worker_restarts`: Количество случаев, когда worker-скрипт был перезапущен целенаправленно. 12 | - `frankenphp_total_threads`: Общее количество потоков PHP. 13 | - `frankenphp_busy_threads`: Количество потоков PHP, которые в данный момент обрабатывают запрос (работающие worker-скрипты всегда используют поток). 14 | 15 | Для метрик worker-скриптов плейсхолдер `[worker]` заменяется на путь к Worker-скрипту, указанному в Caddyfile. 16 | -------------------------------------------------------------------------------- /internal/extgen/nsparser.go: -------------------------------------------------------------------------------- 1 | package extgen 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | type NamespaceParser struct{} 12 | 13 | var namespaceRegex = regexp.MustCompile(`//\s*export_php:namespace\s+(.+)`) 14 | 15 | func (np *NamespaceParser) parse(filename string) (string, error) { 16 | file, err := os.Open(filename) 17 | if err != nil { 18 | return "", err 19 | } 20 | defer func() { 21 | if err := file.Close(); err != nil { 22 | fmt.Printf("Error closing file %s: %v\n", filename, err) 23 | } 24 | }() 25 | 26 | var foundNamespace string 27 | var lineNumber int 28 | var foundLineNumber int 29 | 30 | scanner := bufio.NewScanner(file) 31 | for scanner.Scan() { 32 | lineNumber++ 33 | line := strings.TrimSpace(scanner.Text()) 34 | if matches := namespaceRegex.FindStringSubmatch(line); matches != nil { 35 | namespace := strings.TrimSpace(matches[1]) 36 | if foundNamespace != "" { 37 | return "", fmt.Errorf("multiple namespace declarations found: first at line %d, second at line %d", foundLineNumber, lineNumber) 38 | } 39 | foundNamespace = namespace 40 | foundLineNumber = lineNumber 41 | } 42 | } 43 | 44 | return foundNamespace, scanner.Err() 45 | } 46 | -------------------------------------------------------------------------------- /caddy/php-cli.go: -------------------------------------------------------------------------------- 1 | package caddy 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | 8 | caddycmd "github.com/caddyserver/caddy/v2/cmd" 9 | "github.com/dunglas/frankenphp" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func init() { 15 | caddycmd.RegisterCommand(caddycmd.Command{ 16 | Name: "php-cli", 17 | Usage: "script.php [args ...]", 18 | Short: "Runs a PHP command", 19 | Long: ` 20 | Executes a PHP script similarly to the CLI SAPI.`, 21 | CobraFunc: func(cmd *cobra.Command) { 22 | cmd.DisableFlagParsing = true 23 | cmd.RunE = caddycmd.WrapCommandFuncForCobra(cmdPHPCLI) 24 | }, 25 | }) 26 | } 27 | 28 | func cmdPHPCLI(fs caddycmd.Flags) (int, error) { 29 | args := os.Args[2:] 30 | if len(args) < 1 { 31 | return 1, errors.New("the path to the PHP script is required") 32 | } 33 | 34 | if frankenphp.EmbeddedAppPath != "" { 35 | if _, err := os.Stat(args[0]); err != nil { 36 | args[0] = filepath.Join(frankenphp.EmbeddedAppPath, args[0]) 37 | } 38 | } 39 | 40 | var status int 41 | if len(args) >= 2 && args[0] == "-r" { 42 | status = frankenphp.ExecutePHPCode(args[1]) 43 | } else { 44 | status = frankenphp.ExecuteScriptCLI(args[0], args) 45 | } 46 | 47 | os.Exit(status) 48 | 49 | return status, nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/testext/extensions.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "_cgo_export.h" 5 | 6 | zend_module_entry module1_entry = {STANDARD_MODULE_HEADER, 7 | "ext1", 8 | NULL, /* Functions */ 9 | NULL, /* MINIT */ 10 | NULL, /* MSHUTDOWN */ 11 | NULL, /* RINIT */ 12 | NULL, /* RSHUTDOWN */ 13 | NULL, /* MINFO */ 14 | "0.1.0", 15 | STANDARD_MODULE_PROPERTIES}; 16 | 17 | zend_module_entry module2_entry = {STANDARD_MODULE_HEADER, 18 | "ext2", 19 | NULL, /* Functions */ 20 | NULL, /* MINIT */ 21 | NULL, /* MSHUTDOWN */ 22 | NULL, /* RINIT */ 23 | NULL, /* RSHUTDOWN */ 24 | NULL, /* MINFO */ 25 | "0.1.0", 26 | STANDARD_MODULE_PROPERTIES}; 27 | -------------------------------------------------------------------------------- /package/rhel/postuninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$1" -ge 1 ] && [ -x "/usr/lib/systemd/systemd-update-helper" ]; then 4 | # Package upgrade, not uninstall 5 | /usr/lib/systemd/systemd-update-helper mark-restart-system-units frankenphp.service || : 6 | fi 7 | 8 | if [ "$1" -eq 0 ]; then 9 | if [ -x /usr/sbin/getsebool ]; then 10 | # connect to ACME endpoint to request certificates 11 | setsebool -P httpd_can_network_connect off 12 | fi 13 | if [ -x /usr/sbin/semanage ]; then 14 | # file contexts 15 | semanage fcontext --delete --type httpd_exec_t '/usr/bin/frankenphp' 2>/dev/null || : 16 | semanage fcontext --delete --type httpd_sys_content_t '/usr/share/frankenphp(/.*)?' 2>/dev/null || : 17 | semanage fcontext --delete --type httpd_config_t '/etc/frankenphp(/.*)?' 2>/dev/null || : 18 | semanage fcontext --delete --type httpd_var_lib_t '/var/lib/frankenphp(/.*)?' 2>/dev/null || : 19 | semanage fcontext --delete --type httpd_sys_rw_content_t '/var/lib/frankenphp(/.*\.db)' 2>/dev/null || : 20 | # QUIC 21 | semanage port --delete --type http_port_t --proto udp 80 2>/dev/null || : 22 | semanage port --delete --type http_port_t --proto udp 443 2>/dev/null || : 23 | # admin endpoint 24 | semanage port --delete --type http_port_t --proto tcp 2019 2>/dev/null || : 25 | fi 26 | fi 27 | -------------------------------------------------------------------------------- /testdata/performance/perf-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # install the dev.Dockerfile, build the app and run k6 tests 4 | 5 | docker build -t frankenphp-dev -f dev.Dockerfile . 6 | 7 | export "CADDY_HOSTNAME=http://host.docker.internal" 8 | 9 | select filename in ./testdata/performance/*.js; do 10 | read -r -p "How many worker threads? " workerThreads 11 | read -r -p "How many max threads? " maxThreads 12 | 13 | numThreads=$((workerThreads + 1)) 14 | 15 | docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ 16 | -p 8125:80 \ 17 | -v "$PWD:/go/src/app" \ 18 | --name load-test-container \ 19 | -e "MAX_THREADS=$maxThreads" \ 20 | -e "WORKER_THREADS=$workerThreads" \ 21 | -e "NUM_THREADS=$numThreads" \ 22 | -itd \ 23 | frankenphp-dev \ 24 | sh /go/src/app/testdata/performance/start-server.sh 25 | 26 | docker exec -d load-test-container sh /go/src/app/testdata/performance/flamegraph.sh 27 | 28 | sleep 10 29 | 30 | docker run --entrypoint "" -it --rm -v .:/app -w /app \ 31 | --add-host "host.docker.internal:host-gateway" \ 32 | grafana/k6:latest \ 33 | k6 run -e "CADDY_HOSTNAME=$CADDY_HOSTNAME:8125" "./$filename" 34 | 35 | docker exec load-test-container curl "http://localhost:2019/frankenphp/threads" 36 | 37 | docker stop load-test-container 38 | docker rm load-test-container 39 | done 40 | -------------------------------------------------------------------------------- /caddy/caddy.go: -------------------------------------------------------------------------------- 1 | // Package caddy provides a PHP module for the Caddy web server. 2 | // FrankenPHP embeds the PHP interpreter directly in Caddy, giving it the ability to run your PHP scripts directly. 3 | // No PHP FPM required! 4 | package caddy 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/caddyserver/caddy/v2" 10 | "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 11 | ) 12 | 13 | const ( 14 | defaultDocumentRoot = "public" 15 | defaultWatchPattern = "./**/*.{env,php,twig,yaml,yml}" 16 | ) 17 | 18 | func init() { 19 | caddy.RegisterModule(FrankenPHPApp{}) 20 | caddy.RegisterModule(FrankenPHPModule{}) 21 | caddy.RegisterModule(FrankenPHPAdmin{}) 22 | 23 | httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption) 24 | 25 | httpcaddyfile.RegisterHandlerDirective("php", parseCaddyfile) 26 | httpcaddyfile.RegisterDirectiveOrder("php", "before", "file_server") 27 | 28 | httpcaddyfile.RegisterDirective("php_server", parsePhpServer) 29 | httpcaddyfile.RegisterDirectiveOrder("php_server", "before", "file_server") 30 | } 31 | 32 | // wrongSubDirectiveError returns a nice error message. 33 | func wrongSubDirectiveError(module string, allowedDirectives string, wrongValue string) error { 34 | return fmt.Errorf("unknown %q subdirective: %s (allowed directives are: %s)", module, wrongValue, allowedDirectives) 35 | } 36 | -------------------------------------------------------------------------------- /docs/classic.md: -------------------------------------------------------------------------------- 1 | # Using Classic Mode 2 | 3 | Without any additional configuration, FrankenPHP operates in classic mode. In this mode, FrankenPHP functions like a traditional PHP server, directly serving PHP files. This makes it a seamless drop-in replacement for PHP-FPM or Apache with mod_php. 4 | 5 | Similar to Caddy, FrankenPHP accepts an unlimited number of connections and uses a [fixed number of threads](config.md#caddyfile-config) to serve them. The number of accepted and queued connections is limited only by the available system resources. 6 | The PHP thread pool operates with a fixed number of threads initialized at startup, comparable to the static mode of PHP-FPM. It's also possible to let threads [scale automatically at runtime](performance.md#max_threads), similar to the dynamic mode of PHP-FPM. 7 | 8 | Queued connections will wait indefinitely until a PHP thread is available to serve them. To avoid this, you can use the max_wait_time [configuration](config.md#caddyfile-config) in FrankenPHP's global configuration to limit the duration a request can wait for a free PHP thread before being rejected. 9 | Additionally, you can set a reasonable [write timeout in Caddy](https://caddyserver.com/docs/caddyfile/options#timeouts). 10 | 11 | Each Caddy instance will only spin up one FrankenPHP thread pool, which will be shared across all `php_server` blocks. 12 | -------------------------------------------------------------------------------- /testdata/integration/namespace.go: -------------------------------------------------------------------------------- 1 | package testintegration 2 | 3 | // export_php:namespace TestIntegration\Extension 4 | 5 | // #include 6 | import "C" 7 | import ( 8 | "unsafe" 9 | 10 | "github.com/dunglas/frankenphp" 11 | ) 12 | 13 | // export_php:const 14 | const NAMESPACE_VERSION = "1.0.0" 15 | 16 | // export_php:function greet(string $name): string 17 | func greet(name *C.zend_string) unsafe.Pointer { 18 | str := frankenphp.GoString(unsafe.Pointer(name)) 19 | result := "Hello, " + str + "!" 20 | return frankenphp.PHPString(result, false) 21 | } 22 | 23 | // export_php:class Person 24 | type PersonStruct struct { 25 | Name string 26 | Age int 27 | } 28 | 29 | // export_php:method Person::setName(string $name): void 30 | func (p *PersonStruct) SetName(name *C.zend_string) { 31 | p.Name = frankenphp.GoString(unsafe.Pointer(name)) 32 | } 33 | 34 | // export_php:method Person::getName(): string 35 | func (p *PersonStruct) GetName() unsafe.Pointer { 36 | return frankenphp.PHPString(p.Name, false) 37 | } 38 | 39 | // export_php:method Person::setAge(int $age): void 40 | func (p *PersonStruct) SetAge(age int64) { 41 | p.Age = int(age) 42 | } 43 | 44 | // export_php:method Person::getAge(): int 45 | func (p *PersonStruct) GetAge() int64 { 46 | return int64(p.Age) 47 | } 48 | 49 | // export_php:classconst Person 50 | const DEFAULT_AGE = 18 51 | -------------------------------------------------------------------------------- /internal/extgen/stub.go: -------------------------------------------------------------------------------- 1 | package extgen 2 | 3 | import ( 4 | _ "embed" 5 | "path/filepath" 6 | "strings" 7 | "text/template" 8 | ) 9 | 10 | //go:embed templates/stub.php.tpl 11 | var templateContent string 12 | 13 | type StubGenerator struct { 14 | Generator *Generator 15 | } 16 | 17 | func (sg *StubGenerator) generate() error { 18 | filename := filepath.Join(sg.Generator.BuildDir, sg.Generator.BaseName+".stub.php") 19 | content, err := sg.buildContent() 20 | if err != nil { 21 | return err 22 | } 23 | 24 | return writeFile(filename, content) 25 | } 26 | 27 | func (sg *StubGenerator) buildContent() (string, error) { 28 | tmpl, err := template.New("stub.php.tpl").Funcs(template.FuncMap{ 29 | "phpType": getPhpTypeAnnotation, 30 | }).Parse(templateContent) 31 | if err != nil { 32 | return "", err 33 | } 34 | 35 | var buf strings.Builder 36 | if err := tmpl.Execute(&buf, sg.Generator); err != nil { 37 | return "", err 38 | } 39 | 40 | return buf.String(), nil 41 | } 42 | 43 | // getPhpTypeAnnotation converts phpType to PHP type annotation 44 | func getPhpTypeAnnotation(t phpType) string { 45 | switch t { 46 | case phpString: 47 | return "string" 48 | case phpBool: 49 | return "bool" 50 | case phpFloat: 51 | return "float" 52 | case phpInt: 53 | return "int" 54 | case phpArray: 55 | return "array" 56 | default: 57 | return "int" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/actions/watcher/action.yaml: -------------------------------------------------------------------------------- 1 | name: watcher 2 | description: Install e-dant/watcher 3 | runs: 4 | using: composite 5 | steps: 6 | - name: Determine e-dant/watcher version 7 | id: determine-watcher-version 8 | run: echo version="$(gh release view --repo e-dant/watcher --json tagName --template '{{ .tagName }}')" >> "${GITHUB_OUTPUT}" 9 | shell: bash 10 | env: 11 | GH_TOKEN: ${{ github.token }} 12 | - name: Cache e-dant/watcher 13 | id: cache-watcher 14 | uses: actions/cache@v4 15 | with: 16 | path: watcher/target 17 | key: watcher-${{ runner.os }}-${{ runner.arch }}-${{ steps.determine-watcher-version.outputs.version }}-${{ env.CC && env.CC || 'gcc' }} 18 | - if: steps.cache-watcher.outputs.cache-hit != 'true' 19 | name: Compile e-dant/watcher 20 | run: | 21 | mkdir watcher 22 | gh release download --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1 23 | cd watcher 24 | cmake -S . -B build -DCMAKE_BUILD_TYPE=Release 25 | cmake --build build 26 | sudo cmake --install build --prefix target 27 | shell: bash 28 | env: 29 | GH_TOKEN: ${{ github.token }} 30 | - name: Update LD_LIBRARY_PATH 31 | run: | 32 | sudo sh -c "echo ${PWD}/watcher/target/lib > /etc/ld.so.conf.d/watcher.conf" 33 | sudo ldconfig 34 | shell: bash 35 | -------------------------------------------------------------------------------- /docs/metrics.md: -------------------------------------------------------------------------------- 1 | # Metrics 2 | 3 | When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenPHP exposes the following metrics: 4 | 5 | - `frankenphp_total_threads`: The total number of PHP threads. 6 | - `frankenphp_busy_threads`: The number of PHP threads currently processing a request (running workers always consume a thread). 7 | - `frankenphp_queue_depth`: The number of regular queued requests 8 | - `frankenphp_total_workers{worker="[worker_name]"}`: The total number of workers. 9 | - `frankenphp_busy_workers{worker="[worker_name]"}`: The number of workers currently processing a request. 10 | - `frankenphp_worker_request_time{worker="[worker_name]"}`: The time spent processing requests by all workers. 11 | - `frankenphp_worker_request_count{worker="[worker_name]"}`: The number of requests processed by all workers. 12 | - `frankenphp_ready_workers{worker="[worker_name]"}`: The number of workers that have called `frankenphp_handle_request` at least once. 13 | - `frankenphp_worker_crashes{worker="[worker_name]"}`: The number of times a worker has unexpectedly terminated. 14 | - `frankenphp_worker_restarts{worker="[worker_name]"}`: The number of times a worker has been deliberately restarted. 15 | - `frankenphp_worker_queue_depth{worker="[worker_name]"}`: The number of queued requests. 16 | 17 | For worker metrics, the `[worker_name]` placeholder is replaced by the worker name in the Caddyfile, otherwise absolute path of worker file will be used. 18 | -------------------------------------------------------------------------------- /docs/ru/github-actions.md: -------------------------------------------------------------------------------- 1 | # Использование GitHub Actions 2 | 3 | Этот репозиторий автоматически собирает и публикует Docker-образы в [Docker Hub](https://hub.docker.com/r/dunglas/frankenphp) для каждого одобренного pull request или вашего собственного форка после настройки. 4 | 5 | ## Настройка GitHub Actions 6 | 7 | В настройках репозитория, в разделе "Secrets", добавьте следующие секреты: 8 | 9 | - `REGISTRY_LOGIN_SERVER`: Docker-реестр, который будет использоваться (например, `docker.io`). 10 | - `REGISTRY_USERNAME`: Имя пользователя для входа в реестр (например, `dunglas`). 11 | - `REGISTRY_PASSWORD`: Пароль для входа в реестр (например, токен доступа). 12 | - `IMAGE_NAME`: Имя образа (например, `dunglas/frankenphp`). 13 | 14 | ## Сборка и загрузка образа 15 | 16 | 1. Создайте Pull Request или выполните push в ваш форк. 17 | 2. GitHub Actions соберёт образ и выполнит тесты. 18 | 3. Если сборка пройдёт успешно, образ будет отправлен в реестр с тегом `pr-x`, где `x` — номер PR. 19 | 20 | ## Развёртывание образа 21 | 22 | 1. После слияния Pull Request GitHub Actions выполнит повторные тесты и соберёт новый образ. 23 | 2. Если сборка пройдёт успешно, тег `main` будет обновлён в Docker-реестре. 24 | 25 | ## Релизы 26 | 27 | 1. Создайте новый тег в репозитории. 28 | 2. GitHub Actions соберёт образ и выполнит тесты. 29 | 3. Если сборка пройдёт успешно, образ будет отправлен в реестр с именем тега (например, `v1.2.3` и `v1.2` будут созданы). 30 | 4. Также будет обновлён тег `latest`. 31 | -------------------------------------------------------------------------------- /docs/fr/classic.md: -------------------------------------------------------------------------------- 1 | # Utilisation du mode classique 2 | 3 | Sans aucune configuration additionnelle, FrankenPHP fonctionne en mode classique. Dans ce mode, FrankenPHP fonctionne comme un serveur PHP traditionnel, en servant directement les fichiers PHP. Cela en fait un remplaçant parfait à PHP-FPM ou Apache avec mod_php. 4 | 5 | Comme Caddy, FrankenPHP accepte un nombre illimité de connexions et utilise un [nombre fixe de threads](config.md#configuration-du-caddyfile) pour les servir. Le nombre de connexions acceptées et en attente n'est limité que par les ressources système disponibles. 6 | Le pool de threads PHP fonctionne avec un nombre fixe de threads initialisés au démarrage, comparable au mode statique de PHP-FPM. Il est également possible de laisser les threads [s'adapter automatiquement à l'exécution](performance.md#max_threads), comme dans le mode dynamique de PHP-FPM. 7 | 8 | Les connexions en file d'attente attendront indéfiniment jusqu'à ce qu'un thread PHP soit disponible pour les servir. Pour éviter cela, vous pouvez utiliser la [configuration](config.md#configuration-du-caddyfile) `max_wait_time` dans la configuration globale de FrankenPHP pour limiter la durée pendant laquelle une requête peut attendre un thread PHP libre avant d'être rejetée. 9 | En outre, vous pouvez définir un [délai d'écriture dans Caddy](https://caddyserver.com/docs/caddyfile/options#timeouts) raisonnable. 10 | 11 | Chaque instance de Caddy n'utilisera qu'un seul pool de threads FrankenPHP, qui sera partagé par tous les blocs `php_server`. 12 | -------------------------------------------------------------------------------- /hotreload.go: -------------------------------------------------------------------------------- 1 | //go:build !nomercure && !nowatcher 2 | 3 | package frankenphp 4 | 5 | import ( 6 | "encoding/json" 7 | "log/slog" 8 | 9 | "github.com/dunglas/frankenphp/internal/watcher" 10 | "github.com/dunglas/mercure" 11 | watcherGo "github.com/e-dant/watcher/watcher-go" 12 | ) 13 | 14 | // WithHotReload sets files to watch for file changes to trigger a hot reload update. 15 | func WithHotReload(topic string, hub *mercure.Hub, patterns []string) Option { 16 | return func(o *opt) error { 17 | o.hotReload = append(o.hotReload, &watcher.PatternGroup{ 18 | Patterns: patterns, 19 | Callback: func(events []*watcherGo.Event) { 20 | // Wait for workers to restart before sending the update 21 | go func() { 22 | data, err := json.Marshal(events) 23 | if err != nil { 24 | if globalLogger.Enabled(globalCtx, slog.LevelError) { 25 | globalLogger.LogAttrs(globalCtx, slog.LevelError, "error marshaling watcher events", slog.Any("error", err)) 26 | } 27 | 28 | return 29 | } 30 | 31 | if err := hub.Publish(globalCtx, &mercure.Update{ 32 | Topics: []string{topic}, 33 | Event: mercure.Event{Data: string(data)}, 34 | Debug: globalLogger.Enabled(globalCtx, slog.LevelDebug), 35 | }); err != nil && globalLogger.Enabled(globalCtx, slog.LevelError) { 36 | globalLogger.LogAttrs(globalCtx, slog.LevelError, "error publishing hot reloading Mercure update", slog.Any("error", err)) 37 | } 38 | }() 39 | }, 40 | }) 41 | 42 | return nil 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docs/github-actions.md: -------------------------------------------------------------------------------- 1 | # Using GitHub Actions 2 | 3 | This repository builds and deploys the Docker image to [Docker Hub](https://hub.docker.com/r/dunglas/frankenphp) on 4 | every approved pull request or on your own fork once setup. 5 | 6 | ## Setting up GitHub Actions 7 | 8 | In the repository settings, under secrets, add the following secrets: 9 | 10 | - `REGISTRY_LOGIN_SERVER`: The Docker registry to use (e.g. `docker.io`). 11 | - `REGISTRY_USERNAME`: The username to use to log in to the registry (e.g. `dunglas`). 12 | - `REGISTRY_PASSWORD`: The password to use to log in to the registry (e.g. an access key). 13 | - `IMAGE_NAME`: The name of the image (e.g. `dunglas/frankenphp`). 14 | 15 | ## Building and Pushing the Image 16 | 17 | 1. Create a Pull Request or push to your fork. 18 | 2. GitHub Actions will build the image and run any tests. 19 | 3. If the build is successful, the image will be pushed to the registry using the `pr-x`, where `x` is the PR number, as the tag. 20 | 21 | ## Deploying the Image 22 | 23 | 1. Once the Pull Request is merged, GitHub Actions will again run the tests and build a new image. 24 | 2. If the build is successful, the `main` tag will be updated in the Docker registry. 25 | 26 | ## Releases 27 | 28 | 1. Create a new tag in the repository. 29 | 2. GitHub Actions will build the image and run any tests. 30 | 3. If the build is successful, the image will be pushed to the registry using the tag name as the tag (e.g. `v1.2.3` and `v1.2` will be created). 31 | 4. The `latest` tag will also be updated. 32 | -------------------------------------------------------------------------------- /internal/extgen/hfile.go: -------------------------------------------------------------------------------- 1 | // header.go 2 | package extgen 3 | 4 | import ( 5 | "bytes" 6 | _ "embed" 7 | "path/filepath" 8 | "strings" 9 | "text/template" 10 | ) 11 | 12 | //go:embed templates/extension.h.tpl 13 | var hFileContent string 14 | 15 | type HeaderGenerator struct { 16 | generator *Generator 17 | } 18 | 19 | type TemplateData struct { 20 | BaseName string 21 | HeaderGuard string 22 | Constants []phpConstant 23 | Classes []phpClass 24 | } 25 | 26 | func (hg *HeaderGenerator) generate() error { 27 | filename := filepath.Join(hg.generator.BuildDir, hg.generator.BaseName+".h") 28 | content, err := hg.buildContent() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | return writeFile(filename, content) 34 | } 35 | 36 | func (hg *HeaderGenerator) buildContent() (string, error) { 37 | headerGuard := strings.Map(func(r rune) rune { 38 | if r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { 39 | return r 40 | } 41 | 42 | return '_' 43 | }, hg.generator.BaseName) 44 | 45 | headerGuard = strings.ToUpper(headerGuard) + "_H" 46 | 47 | tmpl, err := template.New("header").Parse(hFileContent) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | var buf bytes.Buffer 53 | err = tmpl.Execute(&buf, TemplateData{ 54 | BaseName: hg.generator.BaseName, 55 | HeaderGuard: headerGuard, 56 | Constants: hg.generator.Constants, 57 | Classes: hg.generator.Classes, 58 | }) 59 | 60 | if err != nil { 61 | return "", err 62 | } 63 | 64 | return buf.String(), nil 65 | } 66 | -------------------------------------------------------------------------------- /caddy/extinit.go: -------------------------------------------------------------------------------- 1 | package caddy 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/dunglas/frankenphp/internal/extgen" 11 | 12 | caddycmd "github.com/caddyserver/caddy/v2/cmd" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func init() { 17 | caddycmd.RegisterCommand(caddycmd.Command{ 18 | Name: "extension-init", 19 | Usage: "go_extension.go [--verbose]", 20 | Short: "Initializes a PHP extension from a Go file (EXPERIMENTAL)", 21 | Long: ` 22 | Initializes a PHP extension from a Go file. This command generates the necessary C files for the extension, including the header and source files, as well as the arginfo file.`, 23 | CobraFunc: func(cmd *cobra.Command) { 24 | cmd.Flags().BoolP("debug", "v", false, "Enable verbose debug logs") 25 | 26 | cmd.RunE = caddycmd.WrapCommandFuncForCobra(cmdInitExtension) 27 | }, 28 | }) 29 | } 30 | 31 | func cmdInitExtension(_ caddycmd.Flags) (int, error) { 32 | if len(os.Args) < 3 { 33 | return 1, errors.New("the path to the Go source is required") 34 | } 35 | 36 | sourceFile := os.Args[2] 37 | baseName := extgen.SanitizePackageName(strings.TrimSuffix(filepath.Base(sourceFile), ".go")) 38 | 39 | generator := extgen.Generator{BaseName: baseName, SourceFile: sourceFile, BuildDir: filepath.Dir(sourceFile)} 40 | 41 | if err := generator.Generate(); err != nil { 42 | return 1, err 43 | } 44 | 45 | log.Printf("PHP extension %q initialized successfully in directory %q", baseName, generator.BuildDir) 46 | 47 | return 0, nil 48 | } 49 | -------------------------------------------------------------------------------- /docs/pt-br/classic.md: -------------------------------------------------------------------------------- 1 | # Usando o modo clássico 2 | 3 | Sem nenhuma configuração adicional, o FrankenPHP opera no modo clássico. 4 | Neste modo, o FrankenPHP funciona como um servidor PHP tradicional, servindo 5 | diretamente arquivos PHP. 6 | Isso o torna um substituto perfeito para PHP-FPM ou Apache com mod_php. 7 | 8 | Semelhante ao Caddy, o FrankenPHP aceita um número ilimitado de conexões e usa 9 | um [número fixo de threads](config.md#configuracao-do-caddyfile) para servi-las. 10 | O número de conexões aceitas e enfileiradas é limitado apenas pelos recursos 11 | disponíveis no sistema. 12 | O pool de threads do PHP opera com um número fixo de threads inicializadas na 13 | inicialização, comparável ao modo estático do PHP-FPM. 14 | Também é possível permitir que as threads 15 | [escalem automaticamente em tempo de execução](performance.md#max_threads), 16 | semelhante ao modo dinâmico do PHP-FPM. 17 | 18 | As conexões enfileiradas aguardarão indefinidamente até que uma thread PHP 19 | esteja disponível para servi-las. 20 | Para evitar isso, você pode usar a 21 | [configuração](config.md#configuracao-do-caddyfile) `max_wait_time` na 22 | configuração global do FrankenPHP para limitar o tempo que uma requisição pode 23 | esperar por uma thread PHP livre antes de ser rejeitada. 24 | Além disso, você pode definir um 25 | [tempo limite de escrita razoável no Caddy](https://caddyserver.com/docs/caddyfile/options#timeouts). 26 | 27 | Cada instância do Caddy ativará apenas um pool de threads do FrankenPHP, que 28 | será compartilhado entre todos os blocos `php_server`. 29 | -------------------------------------------------------------------------------- /testdata/env/test-env.php: -------------------------------------------------------------------------------- 1 | uri query { 23 | # replace authorization REDACTED 24 | # } 25 | # } 26 | #} 27 | 28 | root {$SERVER_ROOT:public/} 29 | encode zstd br gzip 30 | 31 | # Uncomment the following lines to enable Mercure and Vulcain modules 32 | #mercure { 33 | # # Publisher JWT key 34 | # publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} 35 | # # Subscriber JWT key 36 | # subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG} 37 | # # Allow anonymous subscribers (double-check that it's what you want) 38 | # anonymous 39 | # # Enable the subscription API (double-check that it's what you want) 40 | # subscriptions 41 | # # Extra directives 42 | # {$MERCURE_EXTRA_DIRECTIVES} 43 | #} 44 | #vulcain 45 | 46 | {$CADDY_SERVER_EXTRA_DIRECTIVES} 47 | 48 | php_server { 49 | #worker /path/to/your/worker.php 50 | } 51 | } 52 | 53 | # As an alternative to editing the above site block, you can add your own site 54 | # block files in the Caddyfile.d directory, and they will be included as long 55 | # as they use the .caddyfile extension. 56 | 57 | import Caddyfile.d/*.caddyfile 58 | -------------------------------------------------------------------------------- /testdata/integration/constants.go: -------------------------------------------------------------------------------- 1 | package testintegration 2 | 3 | // #include 4 | import "C" 5 | import ( 6 | "unsafe" 7 | 8 | "github.com/dunglas/frankenphp" 9 | ) 10 | 11 | // export_php:const 12 | const TEST_MAX_RETRIES = 100 13 | 14 | // export_php:const 15 | const TEST_API_VERSION = "2.0.0" 16 | 17 | // export_php:const 18 | const TEST_ENABLED = true 19 | 20 | // export_php:const 21 | const TEST_PI = 3.14159 22 | 23 | // export_php:const 24 | const STATUS_PENDING = iota 25 | 26 | // export_php:const 27 | const STATUS_PROCESSING = iota 28 | 29 | // export_php:const 30 | const STATUS_COMPLETED = iota 31 | 32 | // export_php:class Config 33 | type ConfigStruct struct { 34 | Mode int 35 | } 36 | 37 | // export_php:classconst Config 38 | const MODE_DEBUG = 1 39 | 40 | // export_php:classconst Config 41 | const MODE_PRODUCTION = 2 42 | 43 | // export_php:classconst Config 44 | const DEFAULT_TIMEOUT = 30 45 | 46 | // export_php:method Config::setMode(int $mode): void 47 | func (c *ConfigStruct) SetMode(mode int64) { 48 | c.Mode = int(mode) 49 | } 50 | 51 | // export_php:method Config::getMode(): int 52 | func (c *ConfigStruct) GetMode() int64 { 53 | return int64(c.Mode) 54 | } 55 | 56 | // export_php:function test_with_constants(int $status): string 57 | func test_with_constants(status int64) unsafe.Pointer { 58 | var result string 59 | switch status { 60 | case STATUS_PENDING: 61 | result = "pending" 62 | case STATUS_PROCESSING: 63 | result = "processing" 64 | case STATUS_COMPLETED: 65 | result = "completed" 66 | default: 67 | result = "unknown" 68 | } 69 | return frankenphp.PHPString(result, false) 70 | } 71 | -------------------------------------------------------------------------------- /docs/pt-br/metrics.md: -------------------------------------------------------------------------------- 1 | # Métricas 2 | 3 | Quando as [métricas do Caddy](https://caddyserver.com/docs/metrics) estão 4 | habilitadas, o FrankenPHP expõe as seguintes métricas: 5 | 6 | - `frankenphp_total_threads`: O número total de threads PHP. 7 | - `frankenphp_busy_threads`: O número de threads PHP processando uma requisição 8 | no momento (workers em execução sempre consomem uma thread). 9 | - `frankenphp_queue_depth`: O número de requisições regulares na fila. 10 | - `frankenphp_total_workers{worker="[nome_do_worker]"}`: O número total de 11 | workers. 12 | - `frankenphp_busy_workers{worker="[nome_do_worker]"}`: O número de workers 13 | processando uma requisição no momento. 14 | - `frankenphp_worker_request_time{worker="[nome_do_worker]"}`: O tempo gasto no 15 | processamento de requisições por todos os workers. 16 | - `frankenphp_worker_request_count{worker="[nome_do_worker]"}`: O número de 17 | requisições processadas por todos os workers. 18 | - `frankenphp_ready_workers{worker="[nome_do_worker]"}`: O número de workers que 19 | chamaram `frankenphp_handle_request` pelo menos uma vez. 20 | - `frankenphp_worker_crashes{worker="[nome_do_worker]"}`: O número de vezes que 21 | um worker foi encerrado inesperadamente. 22 | - `frankenphp_worker_restarts{worker="[nome_do_worker]"}`: O número de vezes que 23 | um worker foi reiniciado deliberadamente. 24 | - `frankenphp_worker_queue_depth{worker="[nome_do_worker]"}`: O número de 25 | requisições na fila. 26 | 27 | Para métricas de worker, o placeholder `[nome_do_worker]` é substituído pelo 28 | nome do worker no Caddyfile; caso contrário, o caminho absoluto do arquivo do 29 | worker será usado. 30 | -------------------------------------------------------------------------------- /types.c: -------------------------------------------------------------------------------- 1 | #include "types.h" 2 | 3 | zval *get_ht_packed_data(HashTable *ht, uint32_t index) { 4 | if (ht->u.flags & HASH_FLAG_PACKED) { 5 | return &ht->arPacked[index]; 6 | } 7 | return NULL; 8 | } 9 | 10 | Bucket *get_ht_bucket_data(HashTable *ht, uint32_t index) { 11 | if (!(ht->u.flags & HASH_FLAG_PACKED)) { 12 | return &ht->arData[index]; 13 | } 14 | return NULL; 15 | } 16 | 17 | void *__emalloc__(size_t size) { return emalloc(size); } 18 | 19 | void __efree__(void *ptr) { efree(ptr); } 20 | 21 | void __zend_hash_init__(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor, 22 | bool persistent) { 23 | zend_hash_init(ht, nSize, NULL, pDestructor, persistent); 24 | } 25 | 26 | void __zval_null__(zval *zv) { ZVAL_NULL(zv); } 27 | 28 | void __zval_bool__(zval *zv, bool val) { ZVAL_BOOL(zv, val); } 29 | 30 | void __zval_long__(zval *zv, zend_long val) { ZVAL_LONG(zv, val); } 31 | 32 | void __zval_double__(zval *zv, double val) { ZVAL_DOUBLE(zv, val); } 33 | 34 | void __zval_string__(zval *zv, zend_string *str) { ZVAL_STR(zv, str); } 35 | 36 | void __zval_empty_string__(zval *zv) { ZVAL_EMPTY_STRING(zv); } 37 | 38 | void __zval_arr__(zval *zv, zend_array *arr) { ZVAL_ARR(zv, arr); } 39 | 40 | zend_array *__zend_new_array__(uint32_t size) { return zend_new_array(size); } 41 | 42 | int __zend_is_callable__(zval *cb) { return zend_is_callable(cb, 0, NULL); } 43 | 44 | int __call_user_function__(zval *function_name, zval *retval, 45 | uint32_t param_count, zval params[]) { 46 | return call_user_function(CG(function_table), NULL, function_name, retval, 47 | param_count, params); 48 | } 49 | -------------------------------------------------------------------------------- /internal/testext/exttest.go: -------------------------------------------------------------------------------- 1 | package testext 2 | 3 | // #cgo darwin pkg-config: libxml-2.0 4 | // #cgo CFLAGS: -Wall -Werror 5 | // #cgo CFLAGS: -I/usr/local/include -I/usr/local/include/php -I/usr/local/include/php/main -I/usr/local/include/php/TSRM -I/usr/local/include/php/Zend -I/usr/local/include/php/ext -I/usr/local/include/php/ext/date/lib 6 | // #cgo linux CFLAGS: -D_GNU_SOURCE 7 | // #cgo darwin CFLAGS: -I/opt/homebrew/include 8 | // #cgo LDFLAGS: -L/usr/local/lib -L/usr/lib -lphp -lm -lutil 9 | // #cgo linux LDFLAGS: -ldl -lresolv 10 | // #cgo darwin LDFLAGS: -Wl,-rpath,/usr/local/lib -L/opt/homebrew/lib -L/opt/homebrew/opt/libiconv/lib -liconv -ldl 11 | // #include "extension.h" 12 | import "C" 13 | import ( 14 | "github.com/dunglas/frankenphp" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | "io" 18 | "net/http/httptest" 19 | "testing" 20 | "unsafe" 21 | ) 22 | 23 | func testRegisterExtension(t *testing.T) { 24 | frankenphp.RegisterExtension(unsafe.Pointer(&C.module1_entry)) 25 | frankenphp.RegisterExtension(unsafe.Pointer(&C.module2_entry)) 26 | 27 | err := frankenphp.Init() 28 | require.Nil(t, err) 29 | defer frankenphp.Shutdown() 30 | 31 | req := httptest.NewRequest("GET", "http://example.com/index.php", nil) 32 | w := httptest.NewRecorder() 33 | 34 | req, err = frankenphp.NewRequestWithContext(req, frankenphp.WithRequestDocumentRoot("./testdata", false)) 35 | assert.NoError(t, err) 36 | 37 | err = frankenphp.ServeHTTP(w, req) 38 | assert.NoError(t, err) 39 | 40 | resp := w.Result() 41 | body, _ := io.ReadAll(resp.Body) 42 | assert.Contains(t, string(body), "ext1") 43 | assert.Contains(t, string(body), "ext2") 44 | } 45 | -------------------------------------------------------------------------------- /docs/fr/github-actions.md: -------------------------------------------------------------------------------- 1 | # Utilisation de GitHub Actions 2 | 3 | Ce dépôt construit et déploie l'image Docker sur [le Hub Docker](https://hub.docker.com/r/dunglas/frankenphp) pour 4 | chaque pull request approuvée ou sur votre propre fork une fois configuré. 5 | 6 | ## Configuration de GitHub Actions 7 | 8 | Dans les paramètres du dépôt, sous "secrets", ajoutez les secrets suivants : 9 | 10 | - `REGISTRY_LOGIN_SERVER` : Le registre Docker à utiliser (par exemple, `docker.io`). 11 | - `REGISTRY_USERNAME` : Le nom d'utilisateur à utiliser pour se connecter au registre (par exemple, `dunglas`). 12 | - `REGISTRY_PASSWORD` : Le mot de passe à utiliser pour se connecter au registre (par exemple, une clé d'accès). 13 | - `IMAGE_NAME` : Le nom de l'image (par exemple, `dunglas/frankenphp`). 14 | 15 | ## Construction et push de l'image 16 | 17 | 1. Créez une Pull Request ou poussez vers votre fork. 18 | 2. GitHub Actions va construire l'image et exécuter tous les tests. 19 | 3. Si la construction est réussie, l'image sera poussée vers le registre en utilisant le tag `pr-x`, où `x` est le numéro de la PR. 20 | 21 | ## Déploiement de l'image 22 | 23 | 1. Une fois la Pull Request fusionnée, GitHub Actions exécutera à nouveau les tests et construira une nouvelle image. 24 | 2. Si la construction est réussie, le tag `main` sera mis à jour dans le registre Docker. 25 | 26 | ## Releases 27 | 28 | 1. Créez un nouveau tag dans le dépôt. 29 | 2. GitHub Actions va construire l'image et exécuter tous les tests. 30 | 3. Si la compilation est réussie, l'image sera poussée vers le registre en utilisant le nom du tag comme tag (par exemple, `v1.2.3` et `v1.2` seront créés). 31 | 4. Le tag `latest` sera également mis à jour. 32 | -------------------------------------------------------------------------------- /docs/pt-br/github-actions.md: -------------------------------------------------------------------------------- 1 | # Usando GitHub Actions 2 | 3 | Este repositório constrói e implanta a imagem Docker no 4 | [Docker Hub](https://hub.docker.com/r/dunglas/frankenphp) a cada pull request 5 | aprovado ou em seu próprio fork após a configuração. 6 | 7 | ## Configurando GitHub Actions 8 | 9 | Nas configurações do repositório, em "Secrets", adicione os seguintes segredos: 10 | 11 | - `REGISTRY_LOGIN_SERVER`: O registro do Docker a ser usado (por exemplo, 12 | `docker.io`). 13 | - `REGISTRY_USERNAME`: O nome de usuário a ser usado para fazer login no 14 | registro (por exemplo, `dunglas`). 15 | - `REGISTRY_PASSWORD`: A senha a ser usada para fazer login no registro (por 16 | exemplo, uma chave de acesso). 17 | - `IMAGE_NAME`: O nome da imagem (por exemplo, `dunglas/frankenphp`). 18 | 19 | ## Construindo e enviando a imagem 20 | 21 | 1. Crie um pull request ou faça o push para o seu fork. 22 | 2. O GitHub Actions construirá a imagem e executará os testes. 23 | 3. Se a construção for bem-sucedida, a imagem será enviada para o registro 24 | usando a tag `pr-x`, onde `x` é o número do PR. 25 | 26 | ## Implantando a imagem 27 | 28 | 1. Após o merge do pull request, o GitHub Actions executará os testes novamente 29 | e criará uma nova imagem. 30 | 2. Se a construção for bem-sucedida, a tag `main` será atualizada no registro do 31 | Docker. 32 | 33 | ## Versões 34 | 35 | 1. Crie uma nova tag no repositório. 36 | 2. O GitHub Actions construirá a imagem e executará os testes. 37 | 3. Se a construção for bem-sucedida, a imagem será enviada para o registro 38 | usando o nome da tag como tag (por exemplo, `v1.2.3` e `v1.2` serão criadas). 39 | 4. A tag `latest` também será atualizada. 40 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Creates the tags for the library and the Caddy module. 4 | 5 | set -o nounset 6 | set -o errexit 7 | trap 'echo "Aborting due to errexit on line $LINENO. Exit code: $?" >&2' ERR 8 | set -o errtrace 9 | set -o pipefail 10 | set -o xtrace 11 | 12 | if ! type "git" >/dev/null; then 13 | echo "The \"git\" command must be installed." 14 | exit 1 15 | fi 16 | 17 | if ! type "gh" >/dev/null; then 18 | echo "The \"gh\" command must be installed." 19 | exit 1 20 | fi 21 | 22 | if ! type "brew" >/dev/null; then 23 | echo "The \"brew\" command must be installed." 24 | exit 1 25 | fi 26 | 27 | if [[ $# -ne 1 ]]; then 28 | echo "Usage: ./release.sh version" >&2 29 | exit 1 30 | fi 31 | 32 | # Adapted from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string 33 | if [[ ! $1 =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$ ]]; then 34 | echo "Invalid version number: $1" >&2 35 | exit 1 36 | fi 37 | 38 | git checkout main 39 | git pull 40 | 41 | cd caddy/ 42 | go get "github.com/dunglas/frankenphp@v$1" 43 | cd - 44 | 45 | git commit -S -a -m "chore: prepare release $1" || echo "skip" 46 | 47 | git tag -s -m "Version $1" "v$1" 48 | git tag -s -m "Version $1" "caddy/v$1" 49 | git push --follow-tags 50 | 51 | tags=$(git tag --list --sort=-version:refname 'v*') 52 | previous_tag=$(awk 'NR==2 {print;exit}' <<<"${tags}") 53 | 54 | gh release create --draft --generate-notes --latest --notes-start-tag "${previous_tag}" --verify-tag "v$1" 55 | brew bump-formula-pr dunglas/frankenphp/frankenphp --version "$1" 56 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint Code Base 3 | concurrency: 4 | cancel-in-progress: true 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | on: 7 | pull_request: 8 | branches: 9 | - main 10 | push: 11 | branches: 12 | - main 13 | permissions: 14 | contents: read 15 | packages: read 16 | statuses: write 17 | jobs: 18 | build: 19 | name: Lint Code Base 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout Code 23 | uses: actions/checkout@v6 24 | with: 25 | fetch-depth: 0 26 | persist-credentials: false 27 | - name: Lint Code Base 28 | uses: super-linter/super-linter/slim@v8 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | LINTER_RULES_PATH: / 32 | MARKDOWN_CONFIG_FILE: .markdown-lint.yaml 33 | VALIDATE_CPP: false 34 | VALIDATE_JSCPD: false 35 | VALIDATE_GO: false 36 | VALIDATE_GO_MODULES: false 37 | VALIDATE_PHP_PHPCS: false 38 | VALIDATE_PHP_PHPSTAN: false 39 | VALIDATE_PHP_PSALM: false 40 | VALIDATE_TERRAGRUNT: false 41 | VALIDATE_DOCKERFILE_HADOLINT: false 42 | VALIDATE_TRIVY: false 43 | # Prettier, Biome and StandardJS are incompatible 44 | VALIDATE_JAVASCRIPT_PRETTIER: false 45 | VALIDATE_TYPESCRIPT_PRETTIER: false 46 | VALIDATE_BIOME_FORMAT: false 47 | VALIDATE_BIOME_LINT: false 48 | # Conflicts with MARKDOWN 49 | VALIDATE_MARKDOWN_PRETTIER: false 50 | # To re-enable when https://github.com/super-linter/super-linter/issues/7244 will be closed 51 | VALIDATE_EDITORCONFIG: false 52 | -------------------------------------------------------------------------------- /threadinactive.go: -------------------------------------------------------------------------------- 1 | package frankenphp 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/dunglas/frankenphp/internal/state" 7 | ) 8 | 9 | // representation of a thread with no work assigned to it 10 | // implements the threadHandler interface 11 | // each inactive thread weighs around ~350KB 12 | // keeping threads at 'inactive' will consume more memory, but allow a faster transition 13 | type inactiveThread struct { 14 | thread *phpThread 15 | } 16 | 17 | func convertToInactiveThread(thread *phpThread) { 18 | thread.setHandler(&inactiveThread{thread: thread}) 19 | } 20 | 21 | func (handler *inactiveThread) beforeScriptExecution() string { 22 | thread := handler.thread 23 | 24 | switch thread.state.Get() { 25 | case state.TransitionRequested: 26 | return thread.transitionToNewHandler() 27 | 28 | case state.Booting, state.TransitionComplete: 29 | thread.state.Set(state.Inactive) 30 | 31 | // wait for external signal to start or shut down 32 | thread.state.MarkAsWaiting(true) 33 | thread.state.WaitFor(state.TransitionRequested, state.ShuttingDown) 34 | thread.state.MarkAsWaiting(false) 35 | 36 | return handler.beforeScriptExecution() 37 | 38 | case state.ShuttingDown: 39 | // signal to stop 40 | return "" 41 | } 42 | 43 | panic("unexpected state: " + thread.state.Name()) 44 | } 45 | 46 | func (handler *inactiveThread) afterScriptExecution(int) { 47 | panic("inactive threads should not execute scripts") 48 | } 49 | 50 | func (handler *inactiveThread) frankenPHPContext() *frankenPHPContext { 51 | return nil 52 | } 53 | 54 | func (handler *inactiveThread) context() context.Context { 55 | return globalCtx 56 | } 57 | 58 | func (handler *inactiveThread) name() string { 59 | return "Inactive PHP Thread" 60 | } 61 | -------------------------------------------------------------------------------- /internal/extgen/arginfo.go: -------------------------------------------------------------------------------- 1 | package extgen 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | type arginfoGenerator struct { 13 | generator *Generator 14 | } 15 | 16 | func (ag *arginfoGenerator) generate() error { 17 | genStubPath := os.Getenv("GEN_STUB_SCRIPT") 18 | if genStubPath == "" { 19 | genStubPath = "/usr/local/src/php/build/gen_stub.php" 20 | } 21 | 22 | if _, err := os.Stat(genStubPath); err != nil { 23 | return fmt.Errorf(`the PHP "gen_stub.php" file couldn't be found under %q, you can set the "GEN_STUB_SCRIPT" environement variable to set a custom location`, genStubPath) 24 | } 25 | 26 | stubFile := ag.generator.BaseName + ".stub.php" 27 | cmd := exec.Command("php", genStubPath, filepath.Join(ag.generator.BuildDir, stubFile)) 28 | 29 | output, err := cmd.CombinedOutput() 30 | if err != nil { 31 | log.Print("gen_stub.php output:\n", string(output)) 32 | return fmt.Errorf("running gen_stub script: %w\nOutput: %s", err, string(output)) 33 | } 34 | 35 | return ag.fixArginfoFile(stubFile) 36 | } 37 | 38 | func (ag *arginfoGenerator) fixArginfoFile(stubFile string) error { 39 | arginfoFile := strings.TrimSuffix(stubFile, ".stub.php") + "_arginfo.h" 40 | arginfoPath := filepath.Join(ag.generator.BuildDir, arginfoFile) 41 | 42 | content, err := readFile(arginfoPath) 43 | if err != nil { 44 | return fmt.Errorf("reading arginfo file: %w", err) 45 | } 46 | 47 | // FIXME: the script generate "zend_register_internal_class_with_flags" but it is not recognized by the compiler 48 | fixedContent := strings.ReplaceAll(content, 49 | "zend_register_internal_class_with_flags(&ce, NULL, 0)", 50 | "zend_register_internal_class(&ce)") 51 | 52 | return writeFile(arginfoPath, fixedContent) 53 | } 54 | -------------------------------------------------------------------------------- /scaling_test.go: -------------------------------------------------------------------------------- 1 | package frankenphp 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/dunglas/frankenphp/internal/state" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestScaleARegularThreadUpAndDown(t *testing.T) { 12 | t.Cleanup(Shutdown) 13 | 14 | assert.NoError(t, Init( 15 | WithNumThreads(1), 16 | WithMaxThreads(2), 17 | )) 18 | 19 | autoScaledThread := phpThreads[1] 20 | 21 | // scale up 22 | scaleRegularThread() 23 | assert.Equal(t, state.Ready, autoScaledThread.state.Get()) 24 | assert.IsType(t, ®ularThread{}, autoScaledThread.handler) 25 | 26 | // on down-scale, the thread will be marked as inactive 27 | setLongWaitTime(t, autoScaledThread) 28 | deactivateThreads() 29 | assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) 30 | } 31 | 32 | func TestScaleAWorkerThreadUpAndDown(t *testing.T) { 33 | t.Cleanup(Shutdown) 34 | 35 | workerName := "worker1" 36 | workerPath := testDataPath + "/transition-worker-1.php" 37 | assert.NoError(t, Init( 38 | WithNumThreads(2), 39 | WithMaxThreads(3), 40 | WithWorkers(workerName, workerPath, 1, 41 | WithWorkerEnv(map[string]string{}), 42 | WithWorkerWatchMode([]string{}), 43 | WithWorkerMaxFailures(0), 44 | ), 45 | )) 46 | 47 | autoScaledThread := phpThreads[2] 48 | 49 | // scale up 50 | scaleWorkerThread(getWorkerByPath(workerPath)) 51 | assert.Equal(t, state.Ready, autoScaledThread.state.Get()) 52 | 53 | // on down-scale, the thread will be marked as inactive 54 | setLongWaitTime(t, autoScaledThread) 55 | deactivateThreads() 56 | assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) 57 | } 58 | 59 | func setLongWaitTime(t *testing.T, thread *phpThread) { 60 | t.Helper() 61 | 62 | thread.state.SetWaitTime(time.Now().Add(-time.Hour)) 63 | } 64 | -------------------------------------------------------------------------------- /package/rhel/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$1" -eq 1 ] && [ -x "/usr/lib/systemd/systemd-update-helper" ]; then 4 | # Initial installation 5 | /usr/lib/systemd/systemd-update-helper install-system-units frankenphp.service || : 6 | fi 7 | 8 | if [ -x /usr/sbin/getsebool ]; then 9 | # Connect to ACME endpoint to request certificates 10 | setsebool -P httpd_can_network_connect on 11 | fi 12 | 13 | if [ -x /usr/sbin/semanage ] && [ -x /usr/sbin/restorecon ]; then 14 | # file contexts 15 | semanage fcontext --add --type httpd_exec_t '/usr/bin/frankenphp' 2>/dev/null || : 16 | semanage fcontext --add --type httpd_sys_content_t '/usr/share/frankenphp(/.*)?' 2>/dev/null || : 17 | semanage fcontext --add --type httpd_config_t '/etc/frankenphp(/.*)?' 2>/dev/null || : 18 | semanage fcontext --add --type httpd_var_lib_t '/var/lib/frankenphp(/.*)?' 2>/dev/null || : 19 | semanage fcontext --add --type httpd_sys_rw_content_t "/var/lib/frankenphp(/.*\.db)" 2>/dev/null || : 20 | restorecon -r /usr/bin/frankenphp /usr/share/frankenphp /etc/frankenphp /var/lib/frankenphp || : 21 | fi 22 | 23 | if [ -x /usr/sbin/semanage ]; then 24 | # QUIC 25 | semanage port --add --type http_port_t --proto udp 80 2>/dev/null || : 26 | semanage port --add --type http_port_t --proto udp 443 2>/dev/null || : 27 | # admin endpoint 28 | semanage port --add --type http_port_t --proto tcp 2019 2>/dev/null || : 29 | fi 30 | 31 | if command -v setcap >/dev/null 2>&1; then 32 | setcap cap_net_bind_service=+ep /usr/bin/frankenphp || : 33 | fi 34 | 35 | if [ -x /usr/bin/frankenphp ]; then 36 | HOME=/var/lib/frankenphp /usr/bin/frankenphp run --config /dev/null & 37 | FRANKENPHP_PID=$! 38 | HOME=/var/lib/frankenphp /usr/bin/frankenphp trust || : 39 | kill "$FRANKENPHP_PID" || : 40 | wait "$FRANKENPHP_PID" 2>/dev/null || : 41 | fi 42 | -------------------------------------------------------------------------------- /debugstate.go: -------------------------------------------------------------------------------- 1 | package frankenphp 2 | 3 | import ( 4 | "github.com/dunglas/frankenphp/internal/state" 5 | ) 6 | 7 | // EXPERIMENTAL: ThreadDebugState prints the state of a single PHP thread - debugging purposes only 8 | type ThreadDebugState struct { 9 | Index int 10 | Name string 11 | State string 12 | IsWaiting bool 13 | IsBusy bool 14 | WaitingSinceMilliseconds int64 15 | } 16 | 17 | // EXPERIMENTAL: FrankenPHPDebugState prints the state of all PHP threads - debugging purposes only 18 | type FrankenPHPDebugState struct { 19 | ThreadDebugStates []ThreadDebugState 20 | ReservedThreadCount int 21 | } 22 | 23 | // EXPERIMENTAL: DebugState prints the state of all PHP threads - debugging purposes only 24 | func DebugState() FrankenPHPDebugState { 25 | fullState := FrankenPHPDebugState{ 26 | ThreadDebugStates: make([]ThreadDebugState, 0, len(phpThreads)), 27 | ReservedThreadCount: 0, 28 | } 29 | for _, thread := range phpThreads { 30 | if thread.state.Is(state.Reserved) { 31 | fullState.ReservedThreadCount++ 32 | continue 33 | } 34 | fullState.ThreadDebugStates = append(fullState.ThreadDebugStates, threadDebugState(thread)) 35 | } 36 | 37 | return fullState 38 | } 39 | 40 | // threadDebugState creates a small jsonable status message for debugging purposes 41 | func threadDebugState(thread *phpThread) ThreadDebugState { 42 | return ThreadDebugState{ 43 | Index: thread.threadIndex, 44 | Name: thread.name(), 45 | State: thread.state.Name(), 46 | IsWaiting: thread.state.IsInWaitingState(), 47 | IsBusy: !thread.state.IsInWaitingState(), 48 | WaitingSinceMilliseconds: thread.state.WaitTime(), 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /frankenphp.stub.php: -------------------------------------------------------------------------------- 1 | $context Values of the array will be converted to the corresponding Go type (if supported by FrankenPHP) and added to the context of the structured logs using https://pkg.go.dev/log/slog#Attr 55 | */ 56 | function frankenphp_log(string $message, int $level = 0, array $context = []): void {} 57 | -------------------------------------------------------------------------------- /docs/cn/x-sendfile.md: -------------------------------------------------------------------------------- 1 | # 高效服务大型静态文件 (`X-Sendfile`/`X-Accel-Redirect`) 2 | 3 | 通常,静态文件可以直接由 Web 服务器提供服务, 4 | 但有时在发送它们之前需要执行一些 PHP 代码: 5 | 访问控制、统计、自定义 HTTP 头... 6 | 7 | 不幸的是,与直接使用 Web 服务器相比,使用 PHP 服务大型静态文件效率低下 8 | (内存过载、性能降低...)。 9 | 10 | FrankenPHP 让你在执行自定义 PHP 代码**之后**将静态文件的发送委托给 Web 服务器。 11 | 12 | 为此,你的 PHP 应用程序只需定义一个包含要服务的文件路径的自定义 HTTP 头。FrankenPHP 处理其余部分。 13 | 14 | 此功能在 Apache 中称为 **`X-Sendfile`**,在 NGINX 中称为 **`X-Accel-Redirect`**。 15 | 16 | 在以下示例中,我们假设项目的文档根目录是 `public/` 目录, 17 | 并且我们想要使用 PHP 来服务存储在 `public/` 目录外的文件, 18 | 来自名为 `private-files/` 的目录。 19 | 20 | ## 配置 21 | 22 | 首先,将以下配置添加到你的 `Caddyfile` 以启用此功能: 23 | 24 | ```patch 25 | root public/ 26 | # ... 27 | 28 | + # Symfony、Laravel 和其他使用 Symfony HttpFoundation 组件的项目需要 29 | + request_header X-Sendfile-Type x-accel-redirect 30 | + request_header X-Accel-Mapping ../private-files=/private-files 31 | + 32 | + intercept { 33 | + @accel header X-Accel-Redirect * 34 | + handle_response @accel { 35 | + root private-files/ 36 | + rewrite * {resp.header.X-Accel-Redirect} 37 | + method * GET 38 | + 39 | + # 删除 PHP 设置的 X-Accel-Redirect 头以提高安全性 40 | + header -X-Accel-Redirect 41 | + 42 | + file_server 43 | + } 44 | + } 45 | 46 | php_server 47 | ``` 48 | 49 | ## 纯 PHP 50 | 51 | 将相对文件路径(从 `private-files/`)设置为 `X-Accel-Redirect` 头的值: 52 | 53 | ```php 54 | header('X-Accel-Redirect: file.txt'); 55 | ``` 56 | 57 | ## 使用 Symfony HttpFoundation 组件的项目(Symfony、Laravel、Drupal...) 58 | 59 | Symfony HttpFoundation [原生支持此功能](https://symfony.com/doc/current/components/http_foundation.html#serving-files)。 60 | 它将自动确定 `X-Accel-Redirect` 头的正确值并将其添加到响应中。 61 | 62 | ```php 63 | use Symfony\Component\HttpFoundation\BinaryFileResponse; 64 | 65 | BinaryFileResponse::trustXSendfileTypeHeader(); 66 | $response = new BinaryFileResponse(__DIR__.'/../private-files/file.txt'); 67 | 68 | // ... 69 | ``` 70 | -------------------------------------------------------------------------------- /caddy/module_test.go: -------------------------------------------------------------------------------- 1 | package caddy_test 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/caddyserver/caddy/v2/caddytest" 10 | ) 11 | 12 | func TestRootBehavesTheSameOutsideAndInsidePhpServer(t *testing.T) { 13 | tester := caddytest.NewTester(t) 14 | testPortNum, _ := strconv.Atoi(testPort) 15 | testPortTwo := strconv.Itoa(testPortNum + 1) 16 | expectedFileResponse, _ := os.ReadFile("../testdata/files/static.txt") 17 | hostWithRootOutside := "http://localhost:" + testPort 18 | hostWithRootInside := "http://localhost:" + testPortTwo 19 | tester.InitServer(` 20 | { 21 | skip_install_trust 22 | admin localhost:2999 23 | } 24 | 25 | `+hostWithRootOutside+` { 26 | root ../testdata 27 | php_server 28 | } 29 | 30 | `+hostWithRootInside+` { 31 | php_server { 32 | root ../testdata 33 | } 34 | } 35 | `, "caddyfile") 36 | 37 | // serve a static file 38 | tester.AssertGetResponse(hostWithRootOutside+"/files/static.txt", http.StatusOK, string(expectedFileResponse)) 39 | tester.AssertGetResponse(hostWithRootInside+"/files/static.txt", http.StatusOK, string(expectedFileResponse)) 40 | 41 | // serve a php file 42 | tester.AssertGetResponse(hostWithRootOutside+"/hello.php", http.StatusOK, "Hello from PHP") 43 | tester.AssertGetResponse(hostWithRootInside+"/hello.php", http.StatusOK, "Hello from PHP") 44 | 45 | // fallback to index.php 46 | tester.AssertGetResponse(hostWithRootOutside+"/some-path", http.StatusOK, "I am by birth a Genevese (i not set)") 47 | tester.AssertGetResponse(hostWithRootInside+"/some-path", http.StatusOK, "I am by birth a Genevese (i not set)") 48 | 49 | // fallback to directory index ('dirIndex' in module.go) 50 | tester.AssertGetResponse(hostWithRootOutside+"/dirindex/", http.StatusOK, "Hello from directory index.php") 51 | tester.AssertGetResponse(hostWithRootInside+"/dirindex/", http.StatusOK, "Hello from directory index.php") 52 | } 53 | -------------------------------------------------------------------------------- /testdata/integration/class_methods.go: -------------------------------------------------------------------------------- 1 | package testintegration 2 | 3 | // #include 4 | import "C" 5 | import ( 6 | "unsafe" 7 | 8 | "github.com/dunglas/frankenphp" 9 | ) 10 | 11 | // export_php:class Counter 12 | type CounterStruct struct { 13 | Value int 14 | } 15 | 16 | // export_php:method Counter::increment(): void 17 | func (c *CounterStruct) Increment() { 18 | c.Value++ 19 | } 20 | 21 | // export_php:method Counter::decrement(): void 22 | func (c *CounterStruct) Decrement() { 23 | c.Value-- 24 | } 25 | 26 | // export_php:method Counter::getValue(): int 27 | func (c *CounterStruct) GetValue() int64 { 28 | return int64(c.Value) 29 | } 30 | 31 | // export_php:method Counter::setValue(int $value): void 32 | func (c *CounterStruct) SetValue(value int64) { 33 | c.Value = int(value) 34 | } 35 | 36 | // export_php:method Counter::reset(): void 37 | func (c *CounterStruct) Reset() { 38 | c.Value = 0 39 | } 40 | 41 | // export_php:method Counter::addValue(int $amount): int 42 | func (c *CounterStruct) AddValue(amount int64) int64 { 43 | c.Value += int(amount) 44 | return int64(c.Value) 45 | } 46 | 47 | // export_php:method Counter::updateWithNullable(?int $newValue): void 48 | func (c *CounterStruct) UpdateWithNullable(newValue *int64) { 49 | if newValue != nil { 50 | c.Value = int(*newValue) 51 | } 52 | } 53 | 54 | // export_php:class StringHolder 55 | type StringHolderStruct struct { 56 | Data string 57 | } 58 | 59 | // export_php:method StringHolder::setData(string $data): void 60 | func (sh *StringHolderStruct) SetData(data *C.zend_string) { 61 | sh.Data = frankenphp.GoString(unsafe.Pointer(data)) 62 | } 63 | 64 | // export_php:method StringHolder::getData(): string 65 | func (sh *StringHolderStruct) GetData() unsafe.Pointer { 66 | return frankenphp.PHPString(sh.Data, false) 67 | } 68 | 69 | // export_php:method StringHolder::getLength(): int 70 | func (sh *StringHolderStruct) GetLength() int64 { 71 | return int64(len(sh.Data)) 72 | } 73 | -------------------------------------------------------------------------------- /testdata/integration/callable.go: -------------------------------------------------------------------------------- 1 | package testintegration 2 | 3 | // #include 4 | import "C" 5 | import ( 6 | "unsafe" 7 | 8 | "github.com/dunglas/frankenphp" 9 | ) 10 | 11 | // export_php:function my_array_map(array $data, callable $callback): array 12 | func my_array_map(arr *C.zend_array, callback *C.zval) unsafe.Pointer { 13 | goArray, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr)) 14 | if err != nil { 15 | return nil 16 | } 17 | 18 | result := make([]any, len(goArray)) 19 | for i, item := range goArray { 20 | callResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []any{item}) 21 | result[i] = callResult 22 | } 23 | 24 | return frankenphp.PHPPackedArray[any](result) 25 | } 26 | 27 | // export_php:function my_filter(array $data, ?callable $callback): array 28 | func my_filter(arr *C.zend_array, callback *C.zval) unsafe.Pointer { 29 | goArray, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr)) 30 | if err != nil { 31 | return nil 32 | } 33 | 34 | if callback == nil { 35 | return unsafe.Pointer(arr) 36 | } 37 | 38 | result := make([]any, 0) 39 | for _, item := range goArray { 40 | callResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []any{item}) 41 | if boolResult, ok := callResult.(bool); ok && boolResult { 42 | result = append(result, item) 43 | } 44 | } 45 | 46 | return frankenphp.PHPPackedArray[any](result) 47 | } 48 | 49 | // export_php:class Processor 50 | type Processor struct{} 51 | 52 | // export_php:method Processor::transform(string $input, callable $transformer): string 53 | func (p *Processor) Transform(input *C.zend_string, callback *C.zval) unsafe.Pointer { 54 | goInput := frankenphp.GoString(unsafe.Pointer(input)) 55 | 56 | callResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []any{goInput}) 57 | 58 | resultStr, ok := callResult.(string) 59 | if !ok { 60 | return unsafe.Pointer(input) 61 | } 62 | 63 | return frankenphp.PHPString(resultStr, false) 64 | } 65 | -------------------------------------------------------------------------------- /workerextension.go: -------------------------------------------------------------------------------- 1 | package frankenphp 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | // EXPERIMENTAL: Workers allows you to register a worker. 9 | type Workers interface { 10 | // SendRequest calls the closure passed to frankenphp_handle_request() and updates the PHP context . 11 | // The generated HTTP response will be written through the provided writer. 12 | SendRequest(rw http.ResponseWriter, r *http.Request) error 13 | // SendMessage calls the closure passed to frankenphp_handle_request(), passes message as a parameter, and returns the value produced by the closure. 14 | SendMessage(ctx context.Context, message any, rw http.ResponseWriter) (any, error) 15 | // NumThreads returns the number of available threads. 16 | NumThreads() int 17 | } 18 | 19 | type extensionWorkers struct { 20 | name string 21 | fileName string 22 | num int 23 | options []WorkerOption 24 | internalWorker *worker 25 | } 26 | 27 | // EXPERIMENTAL: SendRequest sends an HTTP request to the worker and writes the response to the provided ResponseWriter. 28 | func (w *extensionWorkers) SendRequest(rw http.ResponseWriter, r *http.Request) error { 29 | fr, err := NewRequestWithContext( 30 | r, 31 | WithOriginalRequest(r), 32 | WithWorkerName(w.name), 33 | ) 34 | 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return ServeHTTP(rw, fr) 40 | } 41 | 42 | func (w *extensionWorkers) NumThreads() int { 43 | return w.internalWorker.countThreads() 44 | } 45 | 46 | // EXPERIMENTAL: SendMessage sends a message to the worker and waits for a response. 47 | func (w *extensionWorkers) SendMessage(ctx context.Context, message any, rw http.ResponseWriter) (any, error) { 48 | fc := newFrankenPHPContext() 49 | fc.logger = globalLogger 50 | fc.worker = w.internalWorker 51 | fc.responseWriter = rw 52 | fc.handlerParameters = message 53 | 54 | err := w.internalWorker.handleRequest(contextHolder{context.WithValue(ctx, contextKey, fc), fc}) 55 | 56 | return fc.handlerReturn, err 57 | } 58 | -------------------------------------------------------------------------------- /docs/ja/x-sendfile.md: -------------------------------------------------------------------------------- 1 | # 大きな静的ファイルを効率的に配信する (`X-Sendfile`/`X-Accel-Redirect`) 2 | 3 | 通常、静的ファイルはウェブサーバーによって直接配信されますが、 4 | 時にはファイルを送信する前にPHPコードを実行する必要があります。 5 | 例えば、アクセス制御、統計、カスタムHTTPヘッダーなど 6 | 7 | 残念ながら、PHPを使用して大きな静的ファイルを配信することは、 8 | ウェブサーバーを直接使うより非効率的です(メモリ過負荷、パフォーマンス低下など)。 9 | 10 | FrankenPHPでは、カスタマイズされたPHPコードを実行した**後**に、 11 | 静的ファイルの送信をウェブサーバーに委譲できます。 12 | 13 | この機能を使うには、PHPアプリケーションは提供するファイルのパスを含む 14 | カスタムHTTPヘッダーを定義するだけです。残りの処理はFrankenPHPが行います。 15 | 16 | この機能は、Apacheでは **`X-Sendfile`** 、NGINXでは **`X-Accel-Redirect`** として知られています。 17 | 18 | 以下の例では、プロジェクトのドキュメントルートが`public/`ディレクトリであり、 19 | `public/`ディレクトリの外部に保存されたファイルを 20 | `private-files/`ディレクトリからPHPで提供したいと仮定します。 21 | 22 | ## 設定方法 23 | 24 | まず、この機能を有効にするために以下の設定を`Caddyfile`に追加します: 25 | 26 | ```patch 27 | root public/ 28 | # ... 29 | 30 | + # Symfony や Laravel など、Symfony HttpFoundation コンポーネントを使用するプロジェクトに必要 31 | + request_header X-Sendfile-Type x-accel-redirect 32 | + request_header X-Accel-Mapping ../private-files=/private-files 33 | + 34 | + intercept { 35 | + @accel header X-Accel-Redirect * 36 | + handle_response @accel { 37 | + root private-files/ 38 | + rewrite * {resp.header.X-Accel-Redirect} 39 | + method * GET 40 | + 41 | + # セキュリティ強化のため、 PHP によって設定された X-Accel-Redirect ヘッダーを削除 42 | + header -X-Accel-Redirect 43 | + 44 | + file_server 45 | + } 46 | + } 47 | 48 | php_server 49 | ``` 50 | 51 | ## プレーンなPHPの場合 52 | 53 | `private-files/`からの相対パスを`X-Accel-Redirect`ヘッダーの値として設定します: 54 | 55 | ```php 56 | header('X-Accel-Redirect: file.txt'); 57 | ``` 58 | 59 | ## Symfony HttpFoundationコンポーネントを使用するプロジェクトの場合(Symfony、Laravel、Drupalなど) 60 | 61 | SymfonyのHttpFoundationは[この機能をネイティブサポート](https://symfony.com/doc/current/components/http_foundation.html#serving-files)しており、 62 | `X-Accel-Redirect`ヘッダーの正しい値を自動的に決定してレスポンスに追加します。 63 | 64 | ```php 65 | use Symfony\Component\HttpFoundation\BinaryFileResponse; 66 | 67 | BinaryFileResponse::trustXSendfileTypeHeader(); 68 | $response = new BinaryFileResponse(__DIR__.'/../private-files/file.txt'); 69 | 70 | // ... 71 | ``` 72 | -------------------------------------------------------------------------------- /caddy/admin.go: -------------------------------------------------------------------------------- 1 | package caddy 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/caddyserver/caddy/v2" 7 | "github.com/dunglas/frankenphp" 8 | "net/http" 9 | ) 10 | 11 | type FrankenPHPAdmin struct{} 12 | 13 | // if the id starts with "admin.api" the module will register AdminRoutes via module.Routes() 14 | func (FrankenPHPAdmin) CaddyModule() caddy.ModuleInfo { 15 | return caddy.ModuleInfo{ 16 | ID: "admin.api.frankenphp", 17 | New: func() caddy.Module { return new(FrankenPHPAdmin) }, 18 | } 19 | } 20 | 21 | // EXPERIMENTAL: These routes are not yet stable and may change in the future. 22 | func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute { 23 | return []caddy.AdminRoute{ 24 | { 25 | Pattern: "/frankenphp/workers/restart", 26 | Handler: caddy.AdminHandlerFunc(admin.restartWorkers), 27 | }, 28 | { 29 | Pattern: "/frankenphp/threads", 30 | Handler: caddy.AdminHandlerFunc(admin.threads), 31 | }, 32 | } 33 | } 34 | 35 | func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Request) error { 36 | if r.Method != http.MethodPost { 37 | return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) 38 | } 39 | 40 | frankenphp.RestartWorkers() 41 | caddy.Log().Info("workers restarted from admin api") 42 | admin.success(w, "workers restarted successfully\n") 43 | 44 | return nil 45 | } 46 | 47 | func (admin *FrankenPHPAdmin) threads(w http.ResponseWriter, _ *http.Request) error { 48 | debugState := frankenphp.DebugState() 49 | prettyJson, err := json.MarshalIndent(debugState, "", " ") 50 | if err != nil { 51 | return admin.error(http.StatusInternalServerError, err) 52 | } 53 | 54 | return admin.success(w, string(prettyJson)) 55 | } 56 | 57 | func (admin *FrankenPHPAdmin) success(w http.ResponseWriter, message string) error { 58 | w.WriteHeader(http.StatusOK) 59 | _, err := w.Write([]byte(message)) 60 | return err 61 | } 62 | 63 | func (admin *FrankenPHPAdmin) error(statusCode int, err error) error { 64 | return caddy.APIError{HTTPStatus: statusCode, Err: err} 65 | } 66 | -------------------------------------------------------------------------------- /caddy/hotreload_test.go: -------------------------------------------------------------------------------- 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(" /root/.gdbinit 49 | 50 | WORKDIR /usr/local/src/php 51 | RUN git clone --branch=PHP-8.5 https://github.com/php/php-src.git . && \ 52 | # --enable-embed is necessary to generate libphp.so, but we don't use this SAPI directly 53 | ./buildconf --force && \ 54 | EXTENSION_DIR=/usr/lib/frankenphp/modules ./configure \ 55 | --enable-embed \ 56 | --enable-zts \ 57 | --disable-zend-signals \ 58 | --enable-zend-max-execution-timers \ 59 | --with-config-file-path=/etc/frankenphp/php.ini \ 60 | --with-config-file-scan-dir=/etc/frankenphp/php.d \ 61 | --enable-debug && \ 62 | make -j"$(nproc)" && \ 63 | make install && \ 64 | ldconfig /etc/ld.so.conf.d && \ 65 | mkdir -p /etc/frankenphp/php.d && \ 66 | cp php.ini-development /etc/frankenphp/php.ini && \ 67 | echo "zend_extension=opcache.so" >> /etc/frankenphp/php.ini && \ 68 | echo "opcache.enable=1" >> /etc/frankenphp/php.ini && \ 69 | php --version 70 | 71 | # Install e-dant/watcher (necessary for file watching) 72 | WORKDIR /usr/local/src/watcher 73 | RUN git clone https://github.com/e-dant/watcher . && \ 74 | cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ 75 | cmake --build build/ && \ 76 | cmake --install build 77 | 78 | WORKDIR /go/src/app 79 | COPY . . 80 | 81 | WORKDIR /go/src/app/caddy/frankenphp 82 | RUN ../../go.sh build -buildvcs=false 83 | 84 | WORKDIR /go/src/app 85 | CMD [ "zsh" ] 86 | -------------------------------------------------------------------------------- /internal/extgen/nodes.go: -------------------------------------------------------------------------------- 1 | package extgen 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // phpType represents a PHP type 9 | type phpType string 10 | 11 | const ( 12 | phpString phpType = "string" 13 | phpInt phpType = "int" 14 | phpFloat phpType = "float" 15 | phpBool phpType = "bool" 16 | phpArray phpType = "array" 17 | phpObject phpType = "object" 18 | phpMixed phpType = "mixed" 19 | phpVoid phpType = "void" 20 | phpNull phpType = "null" 21 | phpTrue phpType = "true" 22 | phpFalse phpType = "false" 23 | phpCallable phpType = "callable" 24 | ) 25 | 26 | type phpFunction struct { 27 | Name string 28 | Signature string 29 | GoFunction string 30 | Params []phpParameter 31 | ReturnType phpType 32 | IsReturnNullable bool 33 | lineNumber int 34 | } 35 | 36 | type phpParameter struct { 37 | Name string 38 | PhpType phpType 39 | IsNullable bool 40 | DefaultValue string 41 | HasDefault bool 42 | } 43 | 44 | type phpClass struct { 45 | Name string 46 | GoStruct string 47 | Properties []phpClassProperty 48 | Methods []phpClassMethod 49 | } 50 | 51 | type phpClassMethod struct { 52 | Name string 53 | PhpName string 54 | Signature string 55 | GoFunction string 56 | Wrapper string 57 | Params []phpParameter 58 | ReturnType phpType 59 | isReturnNullable bool 60 | lineNumber int 61 | ClassName string // used by the "//export_php:method" directive 62 | } 63 | 64 | type phpClassProperty struct { 65 | Name string 66 | PhpType phpType 67 | GoType string 68 | IsNullable bool 69 | } 70 | 71 | type phpConstant struct { 72 | Name string 73 | Value string 74 | PhpType phpType 75 | IsIota bool 76 | lineNumber int 77 | ClassName string // empty for global constants, set for class constants 78 | } 79 | 80 | // CValue returns the constant value in C-compatible format 81 | func (c phpConstant) CValue() string { 82 | if c.PhpType != phpInt { 83 | return c.Value 84 | } 85 | 86 | if strings.HasPrefix(c.Value, "0o") { 87 | if val, err := strconv.ParseInt(c.Value, 0, 64); err == nil { 88 | return strconv.FormatInt(val, 10) 89 | } 90 | } 91 | 92 | return c.Value 93 | } 94 | -------------------------------------------------------------------------------- /threadtasks_test.go: -------------------------------------------------------------------------------- 1 | package frankenphp 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/dunglas/frankenphp/internal/state" 8 | ) 9 | 10 | // representation of a thread that handles tasks directly assigned by go 11 | // implements the threadHandler interface 12 | type taskThread struct { 13 | thread *phpThread 14 | execChan chan *task 15 | } 16 | 17 | // task callbacks will be executed directly on the PHP thread 18 | // therefore having full access to the PHP runtime 19 | type task struct { 20 | callback func() 21 | done sync.Mutex 22 | } 23 | 24 | func newTask(cb func()) *task { 25 | t := &task{callback: cb} 26 | t.done.Lock() 27 | 28 | return t 29 | } 30 | 31 | func (t *task) waitForCompletion() { 32 | t.done.Lock() 33 | } 34 | 35 | func convertToTaskThread(thread *phpThread) *taskThread { 36 | handler := &taskThread{ 37 | thread: thread, 38 | execChan: make(chan *task), 39 | } 40 | thread.setHandler(handler) 41 | return handler 42 | } 43 | 44 | func (handler *taskThread) beforeScriptExecution() string { 45 | thread := handler.thread 46 | 47 | switch thread.state.Get() { 48 | case state.TransitionRequested: 49 | return thread.transitionToNewHandler() 50 | case state.Booting, state.TransitionComplete: 51 | thread.state.Set(state.Ready) 52 | handler.waitForTasks() 53 | 54 | return handler.beforeScriptExecution() 55 | case state.Ready: 56 | handler.waitForTasks() 57 | 58 | return handler.beforeScriptExecution() 59 | case state.ShuttingDown: 60 | // signal to stop 61 | return "" 62 | } 63 | panic("unexpected state: " + thread.state.Name()) 64 | } 65 | 66 | func (handler *taskThread) afterScriptExecution(_ int) { 67 | panic("task threads should not execute scripts") 68 | } 69 | 70 | func (handler *taskThread) frankenPHPContext() *frankenPHPContext { 71 | return nil 72 | } 73 | 74 | func (handler *taskThread) context() context.Context { 75 | return nil 76 | } 77 | 78 | func (handler *taskThread) name() string { 79 | return "Task PHP Thread" 80 | } 81 | 82 | func (handler *taskThread) waitForTasks() { 83 | for { 84 | select { 85 | case task := <-handler.execChan: 86 | task.callback() 87 | task.done.Unlock() // unlock the task to signal completion 88 | case <-handler.thread.drainChan: 89 | // thread is shutting down, do not execute the function 90 | return 91 | } 92 | } 93 | } 94 | 95 | func (handler *taskThread) execute(t *task) { 96 | handler.execChan <- t 97 | } 98 | -------------------------------------------------------------------------------- /dev.Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | #checkov:skip=CKV_DOCKER_2 3 | #checkov:skip=CKV_DOCKER_3 4 | FROM golang:1.25 5 | 6 | ENV GOTOOLCHAIN=local 7 | ENV CFLAGS="-ggdb3" 8 | ENV PHPIZE_DEPS="\ 9 | autoconf \ 10 | dpkg-dev \ 11 | file \ 12 | g++ \ 13 | gcc \ 14 | libc-dev \ 15 | make \ 16 | pkg-config \ 17 | re2c" 18 | 19 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 20 | 21 | # hadolint ignore=DL3009 22 | RUN apt-get update && \ 23 | apt-get -y --no-install-recommends install \ 24 | $PHPIZE_DEPS \ 25 | libargon2-dev \ 26 | libbrotli-dev \ 27 | libcurl4-openssl-dev \ 28 | libonig-dev \ 29 | libreadline-dev \ 30 | libsodium-dev \ 31 | libsqlite3-dev \ 32 | libssl-dev \ 33 | libxml2-dev \ 34 | zlib1g-dev \ 35 | bison \ 36 | libnss3-tools \ 37 | # Dev tools \ 38 | git \ 39 | clang \ 40 | cmake \ 41 | llvm \ 42 | gdb \ 43 | valgrind \ 44 | neovim \ 45 | zsh \ 46 | libtool-bin && \ 47 | echo 'set auto-load safe-path /' > /root/.gdbinit && \ 48 | echo '* soft core unlimited' >> /etc/security/limits.conf \ 49 | && \ 50 | apt-get clean 51 | 52 | WORKDIR /usr/local/src/php 53 | RUN git clone --branch=PHP-8.5 https://github.com/php/php-src.git . && \ 54 | # --enable-embed is only necessary to generate libphp.so, we don't use this SAPI directly 55 | ./buildconf --force && \ 56 | EXTENSION_DIR=/usr/lib/frankenphp/modules ./configure \ 57 | --enable-embed \ 58 | --enable-zts \ 59 | --disable-zend-signals \ 60 | --enable-zend-max-execution-timers \ 61 | --with-config-file-path=/etc/frankenphp/php.ini \ 62 | --with-config-file-scan-dir=/etc/frankenphp/php.d \ 63 | --enable-debug && \ 64 | make -j"$(nproc)" && \ 65 | make install && \ 66 | ldconfig && \ 67 | mkdir -p /etc/frankenphp/php.d && \ 68 | cp php.ini-development /etc/frankenphp/php.ini && \ 69 | echo "zend_extension=opcache.so" >> /etc/frankenphp/php.ini && \ 70 | echo "opcache.enable=1" >> /etc/frankenphp/php.ini && \ 71 | php --version 72 | 73 | # Install e-dant/watcher (necessary for file watching) 74 | WORKDIR /usr/local/src/watcher 75 | RUN git clone https://github.com/e-dant/watcher . && \ 76 | cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ 77 | cmake --build build/ && \ 78 | cmake --install build && \ 79 | cp build/libwatcher-c.so /usr/local/lib/libwatcher-c.so && \ 80 | ldconfig 81 | 82 | WORKDIR /go/src/app 83 | COPY --link . ./ 84 | 85 | WORKDIR /go/src/app/caddy/frankenphp 86 | RUN ../../go.sh build -buildvcs=false 87 | 88 | WORKDIR /go/src/app 89 | CMD [ "zsh" ] 90 | -------------------------------------------------------------------------------- /package/debian/postinst.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ "$1" = "configure" ]; then 5 | # Add user and group 6 | if ! getent group frankenphp >/dev/null; then 7 | groupadd --system frankenphp 8 | fi 9 | if ! getent passwd frankenphp >/dev/null; then 10 | useradd --system \ 11 | --gid frankenphp \ 12 | --create-home \ 13 | --home-dir /var/lib/frankenphp \ 14 | --shell /usr/sbin/nologin \ 15 | --comment "FrankenPHP web server" \ 16 | frankenphp 17 | fi 18 | if getent group www-data >/dev/null; then 19 | usermod -aG www-data frankenphp 20 | fi 21 | 22 | # Handle cases where package was installed and then purged; 23 | # user and group will still exist but with no home dir 24 | if [ ! -d /var/lib/frankenphp ]; then 25 | mkdir -p /var/lib/frankenphp 26 | chown frankenphp:frankenphp /var/lib/frankenphp 27 | fi 28 | 29 | # Add log directory with correct permissions 30 | if [ ! -d /var/log/frankenphp ]; then 31 | mkdir -p /var/log/frankenphp 32 | chown frankenphp:frankenphp /var/log/frankenphp 33 | fi 34 | fi 35 | 36 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ]; then 37 | # This will only remove masks created by d-s-h on package removal. 38 | deb-systemd-helper unmask frankenphp.service >/dev/null || true 39 | 40 | # was-enabled defaults to true, so new installations run enable. 41 | if deb-systemd-helper --quiet was-enabled frankenphp.service; then 42 | # Enables the unit on first installation, creates new 43 | # symlinks on upgrades if the unit file has changed. 44 | deb-systemd-helper enable frankenphp.service >/dev/null || true 45 | deb-systemd-invoke start frankenphp.service >/dev/null || true 46 | else 47 | # Update the statefile to add new symlinks (if any), which need to be 48 | # cleaned up on purge. Also remove old symlinks. 49 | deb-systemd-helper update-state frankenphp.service >/dev/null || true 50 | fi 51 | 52 | # Restart only if it was already started 53 | if [ -d /run/systemd/system ]; then 54 | systemctl --system daemon-reload >/dev/null || true 55 | if [ -n "$2" ]; then 56 | deb-systemd-invoke try-restart frankenphp.service >/dev/null || true 57 | fi 58 | fi 59 | fi 60 | 61 | if command -v setcap >/dev/null 2>&1; then 62 | setcap cap_net_bind_service=+ep /usr/bin/frankenphp || true 63 | fi 64 | 65 | if [ -x /usr/bin/frankenphp ]; then 66 | HOME=/var/lib/frankenphp /usr/bin/frankenphp run --config /dev/null & 67 | FRANKENPHP_PID=$! 68 | HOME=/var/lib/frankenphp /usr/bin/frankenphp trust || true 69 | kill "$FRANKENPHP_PID" || true 70 | wait "$FRANKENPHP_PID" 2>/dev/null || true 71 | fi 72 | -------------------------------------------------------------------------------- /internal/extgen/templates/extension.go.tpl: -------------------------------------------------------------------------------- 1 | package {{.PackageName}} 2 | 3 | // #include 4 | // #include "{{.BaseName}}.h" 5 | import "C" 6 | import ( 7 | "unsafe" 8 | 9 | "github.com/dunglas/frankenphp" 10 | {{- range .Imports}} 11 | {{.}} 12 | {{- end}} 13 | ) 14 | 15 | func init() { 16 | frankenphp.RegisterExtension(unsafe.Pointer(&C.{{.BaseName}}_module_entry)) 17 | } 18 | 19 | {{ range .Constants}} 20 | const {{.Name}} = {{.Value}} 21 | 22 | {{- end}} 23 | {{- range .Variables}} 24 | 25 | {{.}} 26 | {{- end}} 27 | {{- range .InternalFunctions}} 28 | {{.}} 29 | 30 | {{- end}} 31 | {{- range .Functions}} 32 | //export {{.Name}} 33 | {{.GoFunction}} 34 | 35 | {{- end}} 36 | {{- range .Classes}} 37 | type {{.GoStruct}} struct { 38 | {{- range .Properties}} 39 | {{.Name}} {{.GoType}} 40 | {{- end}} 41 | } 42 | 43 | {{- end}} 44 | {{- if .Classes}} 45 | //export registerGoObject 46 | func registerGoObject(obj interface{}) C.uintptr_t { 47 | handle := cgo.NewHandle(obj) 48 | return C.uintptr_t(handle) 49 | } 50 | 51 | //export getGoObject 52 | func getGoObject(handle C.uintptr_t) interface{} { 53 | h := cgo.Handle(handle) 54 | return h.Value() 55 | } 56 | 57 | //export removeGoObject 58 | func removeGoObject(handle C.uintptr_t) { 59 | h := cgo.Handle(handle) 60 | h.Delete() 61 | } 62 | 63 | {{- end}} 64 | {{- range $class := .Classes}} 65 | //export create_{{.GoStruct}}_object 66 | func create_{{.GoStruct}}_object() C.uintptr_t { 67 | obj := &{{.GoStruct}}{} 68 | return registerGoObject(obj) 69 | } 70 | 71 | {{- range .Methods}} 72 | {{- if .GoFunction}} 73 | {{.GoFunction}} 74 | {{- end}} 75 | 76 | {{- end}} 77 | {{- range .Methods}} 78 | //export {{.Name}}_wrapper 79 | func {{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zval{{else if eq .PhpType "callable"}}, {{.Name}} *C.zval{{else}}, {{.Name}} {{if .IsNullable}}*{{end}}{{phpTypeToGoType .PhpType}}{{end}}{{end}}){{if not (isVoid .ReturnType)}}{{if isStringOrArray .ReturnType}} unsafe.Pointer{{else}} {{phpTypeToGoType .ReturnType}}{{end}}{{end}} { 80 | obj := getGoObject(handle) 81 | if obj == nil { 82 | {{- if not (isVoid .ReturnType)}} 83 | {{- if isStringOrArray .ReturnType}} 84 | return nil 85 | {{- else}} 86 | var zero {{phpTypeToGoType .ReturnType}} 87 | return zero 88 | {{- end}} 89 | {{- else}} 90 | return 91 | {{- end}} 92 | } 93 | structObj := obj.(*{{$class.GoStruct}}) 94 | {{if not (isVoid .ReturnType)}}return {{end}}structObj.{{.Name | title}}({{range $i, $param := .Params}}{{if $i}}, {{end}}{{$param.Name}}{{end}}) 95 | } 96 | {{end}} 97 | {{- end}} 98 | -------------------------------------------------------------------------------- /internal/extgen/phpfunc.go: -------------------------------------------------------------------------------- 1 | package extgen 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type PHPFuncGenerator struct { 9 | paramParser *ParameterParser 10 | namespace string 11 | } 12 | 13 | func (pfg *PHPFuncGenerator) generate(fn phpFunction) string { 14 | var builder strings.Builder 15 | 16 | paramInfo := pfg.paramParser.analyzeParameters(fn.Params) 17 | 18 | funcName := NamespacedName(pfg.namespace, fn.Name) 19 | builder.WriteString(fmt.Sprintf("PHP_FUNCTION(%s)\n{\n", funcName)) 20 | 21 | if decl := pfg.paramParser.generateParamDeclarations(fn.Params); decl != "" { 22 | builder.WriteString(decl + "\n") 23 | } 24 | 25 | builder.WriteString(pfg.paramParser.generateParamParsing(fn.Params, paramInfo.RequiredCount) + "\n") 26 | 27 | builder.WriteString(pfg.generateGoCall(fn) + "\n") 28 | 29 | if returnCode := pfg.generateReturnCode(fn.ReturnType); returnCode != "" { 30 | builder.WriteString(returnCode + "\n") 31 | } 32 | 33 | builder.WriteString("}\n\n") 34 | 35 | return builder.String() 36 | } 37 | 38 | func (pfg *PHPFuncGenerator) generateGoCall(fn phpFunction) string { 39 | callParams := pfg.paramParser.generateGoCallParams(fn.Params) 40 | 41 | if fn.ReturnType == phpVoid { 42 | return fmt.Sprintf(" %s(%s);", fn.Name, callParams) 43 | } 44 | 45 | if fn.ReturnType == phpString { 46 | return fmt.Sprintf(" zend_string *result = %s(%s);", fn.Name, callParams) 47 | } 48 | 49 | if fn.ReturnType == phpArray { 50 | return fmt.Sprintf(" zend_array *result = %s(%s);", fn.Name, callParams) 51 | } 52 | 53 | if fn.ReturnType == phpMixed { 54 | return fmt.Sprintf(" zval *result = %s(%s);", fn.Name, callParams) 55 | } 56 | 57 | return fmt.Sprintf(" %s result = %s(%s);", pfg.getCReturnType(fn.ReturnType), fn.Name, callParams) 58 | } 59 | 60 | func (pfg *PHPFuncGenerator) getCReturnType(returnType phpType) string { 61 | switch returnType { 62 | case phpInt: 63 | return "long" 64 | case phpFloat: 65 | return "double" 66 | case phpBool: 67 | return "int" 68 | default: 69 | return "void" 70 | } 71 | } 72 | 73 | func (pfg *PHPFuncGenerator) generateReturnCode(returnType phpType) string { 74 | switch returnType { 75 | case phpString: 76 | return ` if (result) { 77 | RETURN_STR(result); 78 | } 79 | 80 | RETURN_EMPTY_STRING();` 81 | case phpInt: 82 | return ` RETURN_LONG(result);` 83 | case phpFloat: 84 | return ` RETURN_DOUBLE(result);` 85 | case phpBool: 86 | return ` RETURN_BOOL(result);` 87 | case phpArray: 88 | return ` if (result) { 89 | RETURN_ARR(result); 90 | } 91 | 92 | RETURN_EMPTY_ARRAY();` 93 | default: 94 | return "" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /docs/x-sendfile.md: -------------------------------------------------------------------------------- 1 | # Efficiently Serving Large Static Files (`X-Sendfile`/`X-Accel-Redirect`) 2 | 3 | Usually, static files can be served directly by the web server, 4 | but sometimes it's necessary to execute some PHP code before sending them: 5 | access control, statistics, custom HTTP headers... 6 | 7 | Unfortunately, using PHP to serve large static files is inefficient compared to 8 | direct use of the web server (memory overload, reduced performance...). 9 | 10 | FrankenPHP lets you delegate the sending of static files to the web server 11 | **after** executing customized PHP code. 12 | 13 | To do this, your PHP application simply needs to define a custom HTTP header 14 | containing the path of the file to be served. FrankenPHP takes care of the rest. 15 | 16 | This feature is known as **`X-Sendfile`** for Apache, and **`X-Accel-Redirect`** for NGINX. 17 | 18 | In the following examples, we assume that the document root of the project is the `public/` directory. 19 | and that we want to use PHP to serve files stored outside the `public/` directory, 20 | from a directory named `private-files/`. 21 | 22 | ## Configuration 23 | 24 | First, add the following configuration to your `Caddyfile` to enable this feature: 25 | 26 | ```patch 27 | root public/ 28 | # ... 29 | 30 | + # Needed for Symfony, Laravel and other projects using the Symfony HttpFoundation component 31 | + request_header X-Sendfile-Type x-accel-redirect 32 | + request_header X-Accel-Mapping ../private-files=/private-files 33 | + 34 | + intercept { 35 | + @accel header X-Accel-Redirect * 36 | + handle_response @accel { 37 | + root private-files/ 38 | + rewrite * {resp.header.X-Accel-Redirect} 39 | + method * GET 40 | + 41 | + # Remove the X-Accel-Redirect header set by PHP for increased security 42 | + header -X-Accel-Redirect 43 | + 44 | + file_server 45 | + } 46 | + } 47 | 48 | php_server 49 | ``` 50 | 51 | ## Plain PHP 52 | 53 | Set the relative file path (from `private-files/`) as the value of the `X-Accel-Redirect` header: 54 | 55 | ```php 56 | header('X-Accel-Redirect: file.txt'); 57 | ``` 58 | 59 | ## Projects using the Symfony HttpFoundation component (Symfony, Laravel, Drupal...) 60 | 61 | Symfony HttpFoundation [natively supports this feature](https://symfony.com/doc/current/components/http_foundation.html#serving-files). 62 | It will automatically determine the correct value for the `X-Accel-Redirect` header and add it to the response. 63 | 64 | ```php 65 | use Symfony\Component\HttpFoundation\BinaryFileResponse; 66 | 67 | BinaryFileResponse::trustXSendfileTypeHeader(); 68 | $response = new BinaryFileResponse(__DIR__.'/../private-files/file.txt'); 69 | 70 | // ... 71 | ``` 72 | -------------------------------------------------------------------------------- /watcher_test.go: -------------------------------------------------------------------------------- 1 | //go:build !nowatcher 2 | 3 | package frankenphp_test 4 | 5 | import ( 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | // we have to wait a few milliseconds for the watcher debounce to take effect 18 | const pollingTime = 250 19 | 20 | // in tests checking for no reload: we will poll 3x250ms = 0.75s 21 | const minTimesToPollForChanges = 3 22 | 23 | // in tests checking for a reload: we will poll a maximum of 60x250ms = 15s 24 | const maxTimesToPollForChanges = 60 25 | 26 | func TestWorkersShouldReloadOnMatchingPattern(t *testing.T) { 27 | watch := []string{"./testdata/**/*.txt"} 28 | 29 | runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { 30 | requestBodyHasReset := pollForWorkerReset(t, handler, maxTimesToPollForChanges) 31 | assert.True(t, requestBodyHasReset) 32 | }, &testOptions{nbParallelRequests: 1, nbWorkers: 1, workerScript: "worker-with-counter.php", watch: watch}) 33 | } 34 | 35 | func TestWorkersShouldNotReloadOnExcludingPattern(t *testing.T) { 36 | watch := []string{"./testdata/**/*.php"} 37 | 38 | runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { 39 | requestBodyHasReset := pollForWorkerReset(t, handler, minTimesToPollForChanges) 40 | assert.False(t, requestBodyHasReset) 41 | }, &testOptions{nbParallelRequests: 1, nbWorkers: 1, workerScript: "worker-with-counter.php", watch: watch}) 42 | } 43 | 44 | func pollForWorkerReset(t *testing.T, handler func(http.ResponseWriter, *http.Request), limit int) bool { 45 | t.Helper() 46 | 47 | // first we make an initial request to start the request counter 48 | body, _ := testGet("http://example.com/worker-with-counter.php", handler, t) 49 | assert.Equal(t, "requests:1", body) 50 | 51 | // now we spam file updates and check if the request counter resets 52 | for range limit { 53 | updateTestFile("./testdata/files/test.txt", "updated", t) 54 | time.Sleep(pollingTime * time.Millisecond) 55 | body, _ := testGet("http://example.com/worker-with-counter.php", handler, t) 56 | if body == "requests:1" { 57 | return true 58 | } 59 | } 60 | 61 | return false 62 | } 63 | 64 | func updateTestFile(fileName string, content string, t *testing.T) { 65 | absFileName, err := filepath.Abs(fileName) 66 | require.NoError(t, err) 67 | 68 | dirName := filepath.Dir(absFileName) 69 | if _, err = os.Stat(dirName); os.IsNotExist(err) { 70 | err = os.MkdirAll(dirName, 0700) 71 | } 72 | require.NoError(t, err) 73 | 74 | require.NoError(t, os.WriteFile(absFileName, []byte(content), 0644)) 75 | } 76 | -------------------------------------------------------------------------------- /docs/fr/x-sendfile.md: -------------------------------------------------------------------------------- 1 | # Servir efficacement les gros fichiers statiques (`X-Sendfile`/`X-Accel-Redirect`) 2 | 3 | Habituellement, les fichiers statiques peuvent être servis directement par le serveur web, 4 | mais parfois, il est nécessaire d'exécuter du code PHP avant de les envoyer : 5 | contrôle d'accès, statistiques, en-têtes HTTP personnalisés... 6 | 7 | Malheureusement, utiliser PHP pour servir de gros fichiers statiques est inefficace comparé à 8 | l'utilisation directe du serveur web (surcharge mémoire, diminution des performances...). 9 | 10 | FrankenPHP permet de déléguer l'envoi des fichiers statiques au serveur web 11 | **après** avoir exécuté du code PHP personnalisé. 12 | 13 | Pour ce faire, votre application PHP n'a qu'à définir un en-tête HTTP personnalisé 14 | contenant le chemin du fichier à servir. FrankenPHP se chargera du reste. 15 | 16 | Cette fonctionnalité est connue sous le nom de **`X-Sendfile`** pour Apache, et **`X-Accel-Redirect`** pour NGINX. 17 | 18 | Dans les exemples suivants, nous supposons que le "document root" du projet est le répertoire `public/` 19 | et que nous voulons utiliser PHP pour servir des fichiers stockés en dehors du dossier `public/`, 20 | depuis un répertoire nommé `private-files/`. 21 | 22 | ## Configuration 23 | 24 | Tout d'abord, ajoutez la configuration suivante à votre `Caddyfile` pour activer cette fonctionnalité : 25 | 26 | ```patch 27 | root public/ 28 | # ... 29 | 30 | + # Needed for Symfony, Laravel and other projects using the Symfony HttpFoundation component 31 | + request_header X-Sendfile-Type x-accel-redirect 32 | + request_header X-Accel-Mapping ../private-files=/private-files 33 | + 34 | + intercept { 35 | + @accel header X-Accel-Redirect * 36 | + handle_response @accel { 37 | + root private-files/ 38 | + rewrite * {resp.header.X-Accel-Redirect} 39 | + method * GET 40 | + 41 | + # Remove the X-Accel-Redirect header set by PHP for increased security 42 | + header -X-Accel-Redirect 43 | + 44 | + file_server 45 | + } 46 | + } 47 | 48 | php_server 49 | ``` 50 | 51 | ## PHP simple 52 | 53 | Définissez le chemin relatif du fichier (à partir de `private-files/`) comme valeur de l'en-tête `X-Accel-Redirect` : 54 | 55 | ```php 56 | header('X-Accel-Redirect: file.txt') ; 57 | ``` 58 | 59 | ## Projets utilisant le composant Symfony HttpFoundation (Symfony, Laravel, Drupal...) 60 | 61 | Symfony HttpFoundation [supporte nativement cette fonctionnalité](https://symfony.com/doc/current/components/http_foundation.html#serving-files). 62 | Il va automatiquement déterminer la bonne valeur pour l'en-tête `X-Accel-Redirect` et l'ajoutera à la réponse. 63 | 64 | ```php 65 | use Symfony\Component\HttpFoundation\BinaryFileResponse; 66 | 67 | BinaryFileResponse::trustXSendfileTypeHeader(); 68 | $response = new BinaryFileResponse(__DIR__.'/../private-files/file.txt'); 69 | 70 | // ... 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/pt-br/x-sendfile.md: -------------------------------------------------------------------------------- 1 | # Servindo arquivos estáticos grandes com eficiência (`X-Sendfile`/`X-Accel-Redirect`) 2 | 3 | Normalmente, arquivos estáticos podem ser servidos diretamente pelo servidor 4 | web, mas às vezes é necessário executar algum código PHP antes de enviá-los: 5 | controle de acesso, estatísticas, cabeçalhos HTTP personalizados... 6 | 7 | Infelizmente, usar PHP para servir arquivos estáticos grandes é ineficiente em 8 | comparação com o uso direto do servidor web (sobrecarga de memória, desempenho 9 | reduzido...). 10 | 11 | O FrankenPHP permite delegar o envio de arquivos estáticos ao servidor web 12 | **após** a execução do código PHP personalizado. 13 | 14 | Para fazer isso, sua aplicação PHP só precisa definir um cabeçalho HTTP 15 | personalizado contendo o caminho do arquivo a ser servido. 16 | O FrankenPHP cuida do resto. 17 | 18 | Esse recurso é conhecido como **`X-Sendfile`** para Apache e 19 | **`X-Accel-Redirect`** para NGINX. 20 | 21 | Nos exemplos a seguir, assumimos que o diretório raiz do projeto é o diretório 22 | `public/` e que queremos usar PHP para servir arquivos armazenados fora do 23 | diretório `public/`, de um diretório chamado `arquivos-privados/`. 24 | 25 | ## Configuração 26 | 27 | Primeiro, adicione a seguinte configuração ao seu `Caddyfile` para habilitar 28 | este recurso: 29 | 30 | ```patch 31 | root public/ 32 | # ... 33 | 34 | + # Necessário para Symfony, Laravel e outros projetos que usam o componente 35 | + # Symfony HttpFoundation 36 | + request_header X-Sendfile-Type x-accel-redirect 37 | + request_header X-Accel-Mapping ../arquivos-privados=/arquivos-privados 38 | + 39 | + intercept { 40 | + @accel header X-Accel-Redirect * 41 | + handle_response @accel { 42 | + root arquivos-privados/ 43 | + rewrite * {resp.header.X-Accel-Redirect} 44 | + method * GET 45 | + 46 | + # Remove o cabeçalho X-Accel-Redirect definido pelo PHP para maior 47 | + # segurança 48 | + header -X-Accel-Redirect 49 | + 50 | + file_server 51 | + } 52 | + } 53 | 54 | php_server 55 | ``` 56 | 57 | ## PHP simples 58 | 59 | Defina o caminho relativo do arquivo (de `arquivos-privados/`) como o valor do 60 | cabeçalho `X-Accel-Redirect`: 61 | 62 | ```php 63 | header('X-Accel-Redirect: arquivo.txt'); 64 | ``` 65 | 66 | ## Projetos que utilizam o componente Symfony HttpFoundation (Symfony, Laravel, Drupal...) 67 | 68 | Symfony HttpFoundation 69 | [suporta nativamente este recurso](https://symfony.com/doc/current/components/http_foundation.html#serving-files). 70 | Ele determinará automaticamente o valor correto para o cabeçalho 71 | `X-Accel-Redirect` e o adicionará à resposta. 72 | 73 | ```php 74 | use Symfony\Component\HttpFoundation\BinaryFileResponse; 75 | 76 | BinaryFileResponse::trustXSendfileTypeHeader(); 77 | $response = new BinaryFileResponse(__DIR__.'/../arquivos-privados/arquivo.txt'); 78 | 79 | // ... 80 | ``` 81 | -------------------------------------------------------------------------------- /workerextension_test.go: -------------------------------------------------------------------------------- 1 | package frankenphp 2 | 3 | import ( 4 | "io" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestWorkersExtension(t *testing.T) { 13 | t.Cleanup(Shutdown) 14 | 15 | readyWorkers := 0 16 | shutdownWorkers := 0 17 | serverStarts := 0 18 | serverShutDowns := 0 19 | 20 | externalWorkers, o := WithExtensionWorkers( 21 | "extensionWorkers", 22 | "testdata/worker.php", 23 | 1, 24 | WithWorkerOnReady(func(id int) { 25 | readyWorkers++ 26 | }), 27 | WithWorkerOnShutdown(func(id int) { 28 | serverShutDowns++ 29 | }), 30 | WithWorkerOnServerStartup(func() { 31 | serverStarts++ 32 | }), 33 | WithWorkerOnServerShutdown(func() { 34 | shutdownWorkers++ 35 | }), 36 | ) 37 | 38 | require.NoError(t, Init(o)) 39 | t.Cleanup(func() { 40 | Shutdown() 41 | assert.Equal(t, 1, shutdownWorkers, "Worker shutdown hook should have been called") 42 | assert.Equal(t, 1, serverShutDowns, "Server shutdown hook should have been called") 43 | }) 44 | 45 | assert.Equal(t, readyWorkers, 1, "Worker thread should have called onReady()") 46 | assert.Equal(t, serverStarts, 1, "Server start hook should have been called") 47 | assert.Equal(t, externalWorkers.NumThreads(), 1, "NumThreads() should report 1 thread") 48 | 49 | // Create a test request 50 | req := httptest.NewRequest("GET", "https://example.com/test/?foo=bar", nil) 51 | req.Header.Set("X-Test-Header", "test-value") 52 | w := httptest.NewRecorder() 53 | 54 | // Inject the request into the worker through the extension 55 | err := externalWorkers.SendRequest(w, req) 56 | assert.NoError(t, err, "Sending request should not produce an error") 57 | 58 | resp := w.Result() 59 | body, _ := io.ReadAll(resp.Body) 60 | 61 | // The worker.php script should output information about the request 62 | // We're just checking that we got a response, not the specific content 63 | assert.NotEmpty(t, body, "Response body should not be empty") 64 | assert.Contains(t, string(body), "Requests handled: 0", "Response body should contain request information") 65 | } 66 | 67 | func TestWorkerExtensionSendMessage(t *testing.T) { 68 | externalWorker, o := WithExtensionWorkers("extensionWorkers", "testdata/message-worker.php", 1) 69 | 70 | err := Init(o) 71 | require.NoError(t, err) 72 | t.Cleanup(Shutdown) 73 | 74 | ret, err := externalWorker.SendMessage(t.Context(), "Hello Workers", nil) 75 | require.NoError(t, err) 76 | 77 | assert.Equal(t, "received message: Hello Workers", ret) 78 | } 79 | 80 | func TestErrorIf2WorkersHaveSameName(t *testing.T) { 81 | _, o1 := WithExtensionWorkers("duplicateWorker", "testdata/worker.php", 1) 82 | _, o2 := WithExtensionWorkers("duplicateWorker", "testdata/worker2.php", 1) 83 | 84 | t.Cleanup(Shutdown) 85 | require.Error(t, Init(o1, o2)) 86 | } 87 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | description: File a bug report 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | Before submitting a bug, please double-check that your problem [is not 11 | a known issue](https://frankenphp.dev/docs/known-issues/) 12 | (especially if you use XDebug or Tideways), and that is has not 13 | [already been reported](https://github.com/php/frankenphp/issues). 14 | - type: textarea 15 | id: what-happened 16 | attributes: 17 | label: What happened? 18 | description: | 19 | Tell us what you do, what you get and what you expected. 20 | Provide us with some step-by-step instructions to reproduce the issue. 21 | validations: 22 | required: true 23 | - type: dropdown 24 | id: build 25 | attributes: 26 | label: Build Type 27 | description: What build of FrankenPHP do you use? 28 | options: 29 | - Docker (Debian Trixie) 30 | - Docker (Debian Bookworm) 31 | - Docker (Alpine) 32 | - deb packages 33 | - rpm packages 34 | - Static binary 35 | - Custom (tell us more in the description) 36 | default: 0 37 | validations: 38 | required: true 39 | - type: dropdown 40 | id: worker 41 | attributes: 42 | label: Worker Mode 43 | description: Does the problem happen only when using the worker mode? 44 | options: 45 | - "Yes" 46 | - "No" 47 | default: 0 48 | validations: 49 | required: true 50 | - type: dropdown 51 | id: os 52 | attributes: 53 | label: Operating System 54 | description: What operating system are you executing FrankenPHP with? 55 | options: 56 | - GNU/Linux 57 | - macOS 58 | - Other (tell us more in the description) 59 | default: 0 60 | validations: 61 | required: true 62 | - type: dropdown 63 | id: arch 64 | attributes: 65 | label: CPU Architecture 66 | description: What CPU architecture are you using? 67 | options: 68 | - x86_64 69 | - Apple Silicon 70 | - x86 71 | - aarch64 72 | - Other (tell us more in the description) 73 | default: 0 74 | - type: textarea 75 | id: php 76 | attributes: 77 | label: PHP configuration 78 | description: | 79 | Please copy and paste the output of the `phpinfo()` function -- remember to remove **sensitive information** like passwords, API keys, etc. 80 | render: shell 81 | validations: 82 | required: true 83 | - type: textarea 84 | id: logs 85 | attributes: 86 | label: Relevant log output 87 | description: | 88 | Please copy and paste any relevant log output. 89 | This will be automatically formatted into code, 90 | so no need for backticks. 91 | render: shell 92 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dunglas/frankenphp 2 | 3 | go 1.25.4 4 | 5 | retract v1.0.0-rc.1 // Human error 6 | 7 | require ( 8 | github.com/Masterminds/sprig/v3 v3.3.0 9 | github.com/dunglas/mercure v0.21.4 10 | github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 11 | github.com/maypok86/otter/v2 v2.2.1 12 | github.com/prometheus/client_golang v1.23.2 13 | github.com/stretchr/testify v1.11.1 14 | golang.org/x/net v0.48.0 15 | ) 16 | 17 | require ( 18 | dario.cat/mergo v1.0.2 // indirect 19 | github.com/Masterminds/goutils v1.1.1 // indirect 20 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 21 | github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect 22 | github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect 23 | github.com/beorn7/perks v1.0.1 // indirect 24 | github.com/bits-and-blooms/bitset v1.24.4 // indirect 25 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 27 | github.com/dunglas/skipfilter v1.0.0 // indirect 28 | github.com/felixge/httpsnoop v1.0.4 // indirect 29 | github.com/fsnotify/fsnotify v1.9.0 // indirect 30 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 31 | github.com/gofrs/uuid/v5 v5.4.0 // indirect 32 | github.com/golang-jwt/jwt/v5 v5.3.0 // indirect 33 | github.com/google/uuid v1.6.0 // indirect 34 | github.com/gorilla/handlers v1.5.2 // indirect 35 | github.com/gorilla/mux v1.8.1 // indirect 36 | github.com/huandu/xstrings v1.5.0 // indirect 37 | github.com/kylelemons/godebug v1.1.0 // indirect 38 | github.com/mitchellh/copystructure v1.2.0 // indirect 39 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 40 | github.com/mschoch/smat v0.2.0 // indirect 41 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 42 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 43 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 44 | github.com/prometheus/client_model v0.6.2 // indirect 45 | github.com/prometheus/common v0.67.4 // indirect 46 | github.com/prometheus/procfs v0.19.2 // indirect 47 | github.com/rogpeppe/go-internal v1.13.1 // indirect 48 | github.com/rs/cors v1.11.1 // indirect 49 | github.com/sagikazarmark/locafero v0.12.0 // indirect 50 | github.com/shopspring/decimal v1.4.0 // indirect 51 | github.com/spf13/afero v1.15.0 // indirect 52 | github.com/spf13/cast v1.10.0 // indirect 53 | github.com/spf13/pflag v1.0.10 // indirect 54 | github.com/spf13/viper v1.21.0 // indirect 55 | github.com/subosito/gotenv v1.6.0 // indirect 56 | github.com/unrolled/secure v1.17.0 // indirect 57 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 58 | go.etcd.io/bbolt v1.4.3 // indirect 59 | go.yaml.in/yaml/v2 v2.4.3 // indirect 60 | go.yaml.in/yaml/v3 v3.0.4 // indirect 61 | golang.org/x/crypto v0.46.0 // indirect 62 | golang.org/x/sys v0.39.0 // indirect 63 | golang.org/x/text v0.32.0 // indirect 64 | google.golang.org/protobuf v1.36.11 // indirect 65 | gopkg.in/yaml.v3 v3.0.1 // indirect 66 | ) 67 | -------------------------------------------------------------------------------- /mercure.go: -------------------------------------------------------------------------------- 1 | //go:build !nomercure 2 | 3 | package frankenphp 4 | 5 | // #include 6 | // #include 7 | import "C" 8 | import ( 9 | "log/slog" 10 | "unsafe" 11 | 12 | "github.com/dunglas/mercure" 13 | ) 14 | 15 | type mercureContext struct { 16 | mercureHub *mercure.Hub 17 | } 18 | 19 | //export go_mercure_publish 20 | func go_mercure_publish(threadIndex C.uintptr_t, topics *C.struct__zval_struct, data *C.zend_string, private bool, id, typ *C.zend_string, retry uint64) (generatedID *C.zend_string, error C.short) { 21 | thread := phpThreads[threadIndex] 22 | ctx := thread.context() 23 | fc := thread.frankenPHPContext() 24 | 25 | if fc.mercureHub == nil { 26 | if fc.logger.Enabled(ctx, slog.LevelError) { 27 | fc.logger.LogAttrs(ctx, slog.LevelError, "No Mercure hub configured") 28 | } 29 | 30 | return nil, 1 31 | } 32 | 33 | u := &mercure.Update{ 34 | Event: mercure.Event{ 35 | Data: GoString(unsafe.Pointer(data)), 36 | ID: GoString(unsafe.Pointer(id)), 37 | Retry: retry, 38 | Type: GoString(unsafe.Pointer(typ)), 39 | }, 40 | Private: private, 41 | Debug: fc.logger.Enabled(ctx, slog.LevelDebug), 42 | } 43 | 44 | zvalType := C.zval_get_type(topics) 45 | switch zvalType { 46 | case C.IS_STRING: 47 | u.Topics = []string{GoString(unsafe.Pointer(*(**C.zend_string)(unsafe.Pointer(&topics.value[0]))))} 48 | case C.IS_ARRAY: 49 | ts, err := GoPackedArray[string](unsafe.Pointer(*(**C.zend_array)(unsafe.Pointer(&topics.value[0])))) 50 | if err != nil { 51 | if fc.logger.Enabled(ctx, slog.LevelError) { 52 | fc.logger.LogAttrs(ctx, slog.LevelError, "invalid topics type", slog.Any("error", err)) 53 | } 54 | 55 | return nil, 1 56 | } 57 | 58 | u.Topics = ts 59 | default: 60 | // Never happens as the function is called from C with proper types 61 | panic("invalid topics type") 62 | } 63 | 64 | if err := fc.mercureHub.Publish(ctx, u); err != nil { 65 | if fc.logger.Enabled(ctx, slog.LevelError) { 66 | fc.logger.LogAttrs(ctx, slog.LevelError, "Unable to publish Mercure update", slog.Any("error", err)) 67 | } 68 | 69 | return nil, 2 70 | } 71 | 72 | return (*C.zend_string)(PHPString(u.ID, false)), 0 73 | } 74 | 75 | func (w *worker) configureMercure(o *workerOpt) { 76 | if o.mercureHub == nil { 77 | return 78 | } 79 | 80 | w.mercureHub = o.mercureHub 81 | } 82 | 83 | // WithMercureHub sets the mercure.Hub to use to publish updates 84 | func WithMercureHub(hub *mercure.Hub) RequestOption { 85 | return func(o *frankenPHPContext) error { 86 | o.mercureHub = hub 87 | 88 | return nil 89 | } 90 | } 91 | 92 | // WithWorkerMercureHub sets the mercure.Hub in the worker script and used to dispatch hot reloading-related mercure.Update. 93 | func WithWorkerMercureHub(hub *mercure.Hub) WorkerOption { 94 | return func(w *workerOpt) error { 95 | w.mercureHub = hub 96 | 97 | w.requestOptions = append(w.requestOptions, WithMercureHub(hub)) 98 | 99 | return nil 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /caddy/hotreload.go: -------------------------------------------------------------------------------- 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 | } 112 | -------------------------------------------------------------------------------- /frankenphp.h: -------------------------------------------------------------------------------- 1 | #ifndef _FRANKENPHP_H 2 | #define _FRANKENPHP_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #ifndef FRANKENPHP_VERSION 10 | #define FRANKENPHP_VERSION dev 11 | #endif 12 | #define STRINGIFY(x) #x 13 | #define TOSTRING(x) STRINGIFY(x) 14 | 15 | typedef struct go_string { 16 | size_t len; 17 | char *data; 18 | } go_string; 19 | 20 | typedef struct ht_key_value_pair { 21 | zend_string *key; 22 | char *val; 23 | size_t val_len; 24 | } ht_key_value_pair; 25 | 26 | typedef struct frankenphp_version { 27 | unsigned char major_version; 28 | unsigned char minor_version; 29 | unsigned char release_version; 30 | const char *extra_version; 31 | const char *version; 32 | unsigned long version_id; 33 | } frankenphp_version; 34 | frankenphp_version frankenphp_get_version(); 35 | 36 | typedef struct frankenphp_config { 37 | bool zts; 38 | bool zend_signals; 39 | bool zend_max_execution_timers; 40 | } frankenphp_config; 41 | frankenphp_config frankenphp_get_config(); 42 | 43 | int frankenphp_new_main_thread(int num_threads); 44 | bool frankenphp_new_php_thread(uintptr_t thread_index); 45 | 46 | bool frankenphp_shutdown_dummy_request(void); 47 | int frankenphp_execute_script(char *file_name); 48 | void frankenphp_update_local_thread_context(bool is_worker); 49 | 50 | int frankenphp_execute_script_cli(char *script, int argc, char **argv, 51 | bool eval); 52 | 53 | void frankenphp_register_variables_from_request_info( 54 | zval *track_vars_array, zend_string *content_type, 55 | zend_string *path_translated, zend_string *query_string, 56 | zend_string *auth_user, zend_string *request_method); 57 | void frankenphp_register_variable_safe(char *key, char *var, size_t val_len, 58 | zval *track_vars_array); 59 | zend_string *frankenphp_init_persistent_string(const char *string, size_t len); 60 | int frankenphp_reset_opcache(void); 61 | int frankenphp_get_current_memory_limit(); 62 | 63 | void frankenphp_register_single(zend_string *z_key, char *value, size_t val_len, 64 | zval *track_vars_array); 65 | void frankenphp_register_bulk( 66 | zval *track_vars_array, ht_key_value_pair remote_addr, 67 | ht_key_value_pair remote_host, ht_key_value_pair remote_port, 68 | ht_key_value_pair document_root, ht_key_value_pair path_info, 69 | ht_key_value_pair php_self, ht_key_value_pair document_uri, 70 | ht_key_value_pair script_filename, ht_key_value_pair script_name, 71 | ht_key_value_pair https, ht_key_value_pair ssl_protocol, 72 | ht_key_value_pair request_scheme, ht_key_value_pair server_name, 73 | ht_key_value_pair server_port, ht_key_value_pair content_length, 74 | ht_key_value_pair gateway_interface, ht_key_value_pair server_protocol, 75 | ht_key_value_pair server_software, ht_key_value_pair http_host, 76 | ht_key_value_pair auth_type, ht_key_value_pair remote_ident, 77 | ht_key_value_pair request_uri, ht_key_value_pair ssl_cipher); 78 | 79 | void register_extensions(zend_module_entry *m, int len); 80 | 81 | #endif 82 | -------------------------------------------------------------------------------- /static-builder-musl.Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | #checkov:skip=CKV_DOCKER_2 3 | #checkov:skip=CKV_DOCKER_3 4 | #checkov:skip=CKV_DOCKER_7 5 | FROM golang-base 6 | 7 | ARG TARGETARCH 8 | 9 | ARG FRANKENPHP_VERSION='' 10 | ENV FRANKENPHP_VERSION=${FRANKENPHP_VERSION} 11 | 12 | ARG PHP_VERSION='' 13 | ENV PHP_VERSION=${PHP_VERSION} 14 | 15 | # args passed to static-php-cli 16 | ARG PHP_EXTENSIONS='' 17 | ARG PHP_EXTENSION_LIBS='' 18 | ARG SPC_OPT_BUILD_ARGS 19 | 20 | # args passed to xcaddy 21 | ARG XCADDY_ARGS='--with github.com/dunglas/caddy-cbrotli --with github.com/dunglas/mercure/caddy --with github.com/dunglas/vulcain/caddy' 22 | ENV SPC_CMD_VAR_FRANKENPHP_XCADDY_MODULES="${XCADDY_ARGS}" 23 | ARG CLEAN='' 24 | ARG EMBED='' 25 | ARG DEBUG_SYMBOLS='' 26 | ARG MIMALLOC='' 27 | ARG NO_COMPRESS='' 28 | 29 | ENV GOTOOLCHAIN=local 30 | 31 | SHELL ["/bin/ash", "-eo", "pipefail", "-c"] 32 | 33 | ARG CI 34 | ENV CI=${CI} 35 | 36 | LABEL org.opencontainers.image.title=FrankenPHP 37 | LABEL org.opencontainers.image.description="The modern PHP app server" 38 | LABEL org.opencontainers.image.url=https://frankenphp.dev 39 | LABEL org.opencontainers.image.source=https://github.com/php/frankenphp 40 | LABEL org.opencontainers.image.licenses=MIT 41 | LABEL org.opencontainers.image.vendor="Kévin Dunglas" 42 | 43 | RUN apk update; \ 44 | apk add --no-cache \ 45 | alpine-sdk \ 46 | autoconf \ 47 | automake \ 48 | bash \ 49 | binutils \ 50 | bison \ 51 | build-base \ 52 | cmake \ 53 | curl \ 54 | file \ 55 | flex \ 56 | g++ \ 57 | gcc \ 58 | git \ 59 | jq \ 60 | libgcc \ 61 | libstdc++ \ 62 | libtool \ 63 | linux-headers \ 64 | m4 \ 65 | make \ 66 | pkgconfig \ 67 | php84 \ 68 | php84-common \ 69 | php84-ctype \ 70 | php84-curl \ 71 | php84-dom \ 72 | php84-iconv \ 73 | php84-mbstring \ 74 | php84-openssl \ 75 | php84-pcntl \ 76 | php84-phar \ 77 | php84-posix \ 78 | php84-session \ 79 | php84-sodium \ 80 | php84-tokenizer \ 81 | php84-xml \ 82 | php84-xmlwriter \ 83 | upx \ 84 | wget \ 85 | xz ; \ 86 | ln -sf /usr/bin/php84 /usr/bin/php && \ 87 | go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest 88 | 89 | # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser 90 | ENV COMPOSER_ALLOW_SUPERUSER=1 91 | COPY --from=composer/composer:2-bin /composer /usr/bin/composer 92 | 93 | WORKDIR /go/src/app 94 | COPY go.mod go.sum ./ 95 | RUN go mod download 96 | 97 | WORKDIR /go/src/app/caddy 98 | COPY caddy/go.mod caddy/go.sum ./ 99 | RUN go mod download 100 | 101 | WORKDIR /go/src/app 102 | COPY --link . ./ 103 | 104 | ENV SPC_DEFAULT_C_FLAGS='-fPIE -fPIC -O3' 105 | ENV SPC_LIBC='musl' 106 | ENV SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS_PROGRAM='-Wl,-O3 -pie' 107 | # Keep default config paths and append any externally provided SPC_OPT_BUILD_ARGS (e.g., from CI) 108 | ENV SPC_OPT_BUILD_ARGS="--with-config-file-path=/etc/frankenphp --with-config-file-scan-dir=/etc/frankenphp/php.d ${SPC_OPT_BUILD_ARGS}" 109 | ENV SPC_REL_TYPE='binary' 110 | ENV EXTENSION_DIR='/usr/lib/frankenphp/modules' 111 | 112 | RUN --mount=type=secret,id=github-token GITHUB_TOKEN=$(cat /run/secrets/github-token) ./build-static.sh 113 | --------------------------------------------------------------------------------