mirror of
https://github.com/dunglas/frankenphp.git
synced 2025-12-24 13:38:11 +08:00
feat: add experimental CLI support (#239)
* feat: add CLI support * updated * debug * fix tests * Caddy php-cli command * use thread * $_SERVER and input streams support * Update frankenphp.c Co-authored-by: Francis Lavoie <lavofr@gmail.com> --------- Co-authored-by: Francis Lavoie <lavofr@gmail.com>
This commit is contained in:
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -41,6 +41,10 @@ jobs:
|
||||
run: go build
|
||||
env:
|
||||
GOEXPERIMENT: cgocheck2
|
||||
-
|
||||
name: Build testcli binary
|
||||
working-directory: internal/testcli/
|
||||
run: go build
|
||||
-
|
||||
name: Run library tests
|
||||
run: go test -race -v ./...
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/caddy/frankenphp/frankenphp
|
||||
/internal/testserver/testserver
|
||||
internal/testcli/testcli
|
||||
.idea/
|
||||
.vscode/
|
||||
__debug_bin
|
||||
|
||||
37
caddy/php-cli.go
Normal file
37
caddy/php-cli.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
caddycmd "github.com/caddyserver/caddy/v2/cmd"
|
||||
"github.com/dunglas/frankenphp"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
caddycmd.RegisterCommand(caddycmd.Command{
|
||||
Name: "php-cli",
|
||||
Usage: "script.php [args ...]",
|
||||
Short: "Runs a PHP command",
|
||||
Long: `
|
||||
Executes a PHP script similarly to the CLI SAPI.`,
|
||||
CobraFunc: func(cmd *cobra.Command) {
|
||||
cmd.DisableFlagParsing = true
|
||||
cmd.RunE = caddycmd.WrapCommandFuncForCobra(cmdPHPCLI)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func cmdPHPCLI(fs caddycmd.Flags) (int, error) {
|
||||
args := os.Args[2:]
|
||||
if len(args) < 1 {
|
||||
return 1, errors.New("the path to the PHP script is required")
|
||||
}
|
||||
|
||||
status := frankenphp.ExecuteScriptCLI(args[0], args)
|
||||
os.Exit(status)
|
||||
|
||||
return status, nil
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
func init() {
|
||||
caddycmd.RegisterCommand(caddycmd.Command{
|
||||
Name: "php-server",
|
||||
Usage: "[--domain <example.com>] [--root <path>] [--listen <addr>] [--access-log]",
|
||||
Usage: "[--domain <example.com>] [--root <path>] [--listen <addr>] [--access-log] [--debug] [--no-compress]",
|
||||
Short: "Spins up a production-ready PHP server",
|
||||
Long: `
|
||||
A simple but production-ready PHP server. Useful for quick deployments,
|
||||
131
frankenphp.c
131
frankenphp.c
@@ -12,6 +12,7 @@
|
||||
#include <Zend/zend_types.h>
|
||||
#include <Zend/zend_exceptions.h>
|
||||
#include <Zend/zend_interfaces.h>
|
||||
#include <sapi/embed/php_embed.h>
|
||||
#include <ext/standard/head.h>
|
||||
#include <ext/spl/spl_exceptions.h>
|
||||
|
||||
@@ -663,3 +664,133 @@ int frankenphp_execute_script(const char* file_name)
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
// Use global variables to store CLI arguments to prevent useless allocations
|
||||
static char *cli_script;
|
||||
static int cli_argc;
|
||||
static char **cli_argv;
|
||||
|
||||
// Adapted from https://github.com/php/php-src/sapi/cli/php_cli.c (The PHP Group, The PHP License)
|
||||
static void cli_register_file_handles(bool no_close) /* {{{ */
|
||||
{
|
||||
php_stream *s_in, *s_out, *s_err;
|
||||
php_stream_context *sc_in=NULL, *sc_out=NULL, *sc_err=NULL;
|
||||
zend_constant ic, oc, ec;
|
||||
|
||||
s_in = php_stream_open_wrapper_ex("php://stdin", "rb", 0, NULL, sc_in);
|
||||
s_out = php_stream_open_wrapper_ex("php://stdout", "wb", 0, NULL, sc_out);
|
||||
s_err = php_stream_open_wrapper_ex("php://stderr", "wb", 0, NULL, sc_err);
|
||||
|
||||
if (s_in==NULL || s_out==NULL || s_err==NULL) {
|
||||
if (s_in) php_stream_close(s_in);
|
||||
if (s_out) php_stream_close(s_out);
|
||||
if (s_err) php_stream_close(s_err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (no_close) {
|
||||
s_in->flags |= PHP_STREAM_FLAG_NO_CLOSE;
|
||||
s_out->flags |= PHP_STREAM_FLAG_NO_CLOSE;
|
||||
s_err->flags |= PHP_STREAM_FLAG_NO_CLOSE;
|
||||
}
|
||||
|
||||
//s_in_process = s_in;
|
||||
|
||||
php_stream_to_zval(s_in, &ic.value);
|
||||
php_stream_to_zval(s_out, &oc.value);
|
||||
php_stream_to_zval(s_err, &ec.value);
|
||||
|
||||
ZEND_CONSTANT_SET_FLAGS(&ic, CONST_CS, 0);
|
||||
ic.name = zend_string_init_interned("STDIN", sizeof("STDIN")-1, 0);
|
||||
zend_register_constant(&ic);
|
||||
|
||||
ZEND_CONSTANT_SET_FLAGS(&oc, CONST_CS, 0);
|
||||
oc.name = zend_string_init_interned("STDOUT", sizeof("STDOUT")-1, 0);
|
||||
zend_register_constant(&oc);
|
||||
|
||||
ZEND_CONSTANT_SET_FLAGS(&ec, CONST_CS, 0);
|
||||
ec.name = zend_string_init_interned("STDERR", sizeof("STDERR")-1, 0);
|
||||
zend_register_constant(&ec);
|
||||
}
|
||||
/* }}} */
|
||||
|
||||
static void sapi_cli_register_variables(zval *track_vars_array) /* {{{ */
|
||||
{
|
||||
size_t len;
|
||||
char *docroot = "";
|
||||
|
||||
/* In CGI mode, we consider the environment to be a part of the server
|
||||
* variables
|
||||
*/
|
||||
php_import_environment_variables(track_vars_array);
|
||||
|
||||
/* Build the special-case PHP_SELF variable for the CLI version */
|
||||
len = strlen(cli_script);
|
||||
if (sapi_module.input_filter(PARSE_SERVER, "PHP_SELF", &cli_script, len, &len)) {
|
||||
php_register_variable("PHP_SELF", cli_script, track_vars_array);
|
||||
}
|
||||
if (sapi_module.input_filter(PARSE_SERVER, "SCRIPT_NAME", &cli_script, len, &len)) {
|
||||
php_register_variable("SCRIPT_NAME", cli_script, track_vars_array);
|
||||
}
|
||||
/* filenames are empty for stdin */
|
||||
if (sapi_module.input_filter(PARSE_SERVER, "SCRIPT_FILENAME", &cli_script, len, &len)) {
|
||||
php_register_variable("SCRIPT_FILENAME", cli_script, track_vars_array);
|
||||
}
|
||||
if (sapi_module.input_filter(PARSE_SERVER, "PATH_TRANSLATED", &cli_script, len, &len)) {
|
||||
php_register_variable("PATH_TRANSLATED", cli_script, track_vars_array);
|
||||
}
|
||||
/* just make it available */
|
||||
len = 0U;
|
||||
if (sapi_module.input_filter(PARSE_SERVER, "DOCUMENT_ROOT", &docroot, len, &len)) {
|
||||
php_register_variable("DOCUMENT_ROOT", docroot, track_vars_array);
|
||||
}
|
||||
}
|
||||
/* }}} */
|
||||
|
||||
static void * execute_script_cli(void *arg) {
|
||||
void *exit_status;
|
||||
|
||||
// The SAPI name "cli" is hardcoded into too many programs... let's usurp it.
|
||||
php_embed_module.name = "cli";
|
||||
php_embed_module.pretty_name = "PHP CLI embedded in FrankenPHP";
|
||||
php_embed_module.register_server_variables = sapi_cli_register_variables;
|
||||
|
||||
php_embed_init(cli_argc, cli_argv);
|
||||
|
||||
cli_register_file_handles(false);
|
||||
zend_first_try {
|
||||
zend_file_handle file_handle;
|
||||
zend_stream_init_filename(&file_handle, cli_script);
|
||||
|
||||
php_execute_script(&file_handle);
|
||||
} zend_end_try();
|
||||
|
||||
exit_status = (void *) (intptr_t) EG(exit_status);
|
||||
|
||||
php_embed_shutdown();
|
||||
|
||||
return exit_status;
|
||||
}
|
||||
|
||||
int frankenphp_execute_script_cli(char *script, int argc, char **argv) {
|
||||
pthread_t thread;
|
||||
int err;
|
||||
void *exit_status;
|
||||
|
||||
cli_script = script;
|
||||
cli_argc = argc;
|
||||
cli_argv = argv;
|
||||
|
||||
// Start the script in a dedicated thread to prevent conflicts between Go and PHP signal handlers
|
||||
err = pthread_create(&thread, NULL, execute_script_cli, NULL);
|
||||
if (err != 0) {
|
||||
return err;
|
||||
}
|
||||
|
||||
err = pthread_join(thread, &exit_status);
|
||||
if (err != 0) {
|
||||
return err;
|
||||
}
|
||||
|
||||
return (intptr_t) exit_status;
|
||||
}
|
||||
|
||||
@@ -666,3 +666,18 @@ func go_log(message *C.char, level C.int) {
|
||||
l.Info(m, zap.Stringer("syslog_level", syslogLevel(level)))
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteScriptCLI executes the PHP script passed as parameter.
|
||||
// It returns the exit status code of the script.
|
||||
func ExecuteScriptCLI(script string, args []string) int {
|
||||
cScript := C.CString(script)
|
||||
defer C.free(unsafe.Pointer(cScript))
|
||||
|
||||
argc := C.int(len(args))
|
||||
argv := make([]*C.char, argc)
|
||||
for i, arg := range args {
|
||||
argv[i] = C.CString(arg)
|
||||
}
|
||||
|
||||
return int(C.frankenphp_execute_script_cli(cScript, argc, (**C.char)(unsafe.Pointer(&argv[0]))))
|
||||
}
|
||||
|
||||
@@ -53,4 +53,6 @@ int frankenphp_execute_script(const char *file_name);
|
||||
uintptr_t frankenphp_request_shutdown();
|
||||
void frankenphp_register_bulk_variables(char **variables, size_t size, zval *track_vars_array);
|
||||
|
||||
int frankenphp_execute_script_cli(char *script, int argc, char **argv);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -85,29 +86,6 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func BenchmarkHelloWorld(b *testing.B) {
|
||||
if err := frankenphp.Init(frankenphp.WithLogger(zap.NewNop())); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer frankenphp.Shutdown()
|
||||
cwd, _ := os.Getwd()
|
||||
testDataDir := cwd + "/testdata/"
|
||||
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
req := frankenphp.NewRequestWithContext(r, testDataDir, nil)
|
||||
if err := frankenphp.ServeHTTP(w, req); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/index.php", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
handler(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelloWorld_module(t *testing.T) { testHelloWorld(t, nil) }
|
||||
func TestHelloWorld_worker(t *testing.T) {
|
||||
testHelloWorld(t, &testOptions{workerScript: "index.php"})
|
||||
@@ -557,21 +535,6 @@ func TestVersion(t *testing.T) {
|
||||
assert.NotEmpty(t, v.Version, 0)
|
||||
}
|
||||
|
||||
func ExampleServeHTTP() {
|
||||
if err := frankenphp.Init(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer frankenphp.Shutdown()
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
req := frankenphp.NewRequestWithContext(r, "/path/to/document/root", nil)
|
||||
if err := frankenphp.ServeHTTP(w, req); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
func TestFiberNoCgo_module(t *testing.T) { testFiberNoCgo(t, &testOptions{}) }
|
||||
func TestFiberNonCgo_worker(t *testing.T) {
|
||||
testFiberNoCgo(t, &testOptions{workerScript: "fiber-no-cgo.php"})
|
||||
@@ -588,3 +551,70 @@ func testFiberNoCgo(t *testing.T, opts *testOptions) {
|
||||
assert.Equal(t, string(body), fmt.Sprintf("Fiber %d", i))
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func TestExecuteScriptCLI(t *testing.T) {
|
||||
if _, err := os.Stat("internal/testcli/testcli"); err != nil {
|
||||
t.Skip("internal/testcli/testcli has not been compiled, run `cd internal/testcli/ && go build`")
|
||||
}
|
||||
|
||||
cmd := exec.Command("internal/testcli/testcli", "testdata/command.php", "foo", "bar")
|
||||
stdoutStderr, err := cmd.CombinedOutput()
|
||||
assert.Error(t, err)
|
||||
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
assert.Equal(t, 3, exitError.ExitCode())
|
||||
}
|
||||
|
||||
stdoutStderrStr := string(stdoutStderr)
|
||||
|
||||
assert.Contains(t, stdoutStderrStr, `"foo"`)
|
||||
assert.Contains(t, stdoutStderrStr, `"bar"`)
|
||||
assert.Contains(t, stdoutStderrStr, "From the CLI")
|
||||
}
|
||||
|
||||
func ExampleServeHTTP() {
|
||||
if err := frankenphp.Init(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer frankenphp.Shutdown()
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
req := frankenphp.NewRequestWithContext(r, "/path/to/document/root", nil)
|
||||
if err := frankenphp.ServeHTTP(w, req); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
func ExampleExecuteScriptCLI() {
|
||||
if len(os.Args) <= 1 {
|
||||
log.Println("Usage: my-program script.php")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.Exit(frankenphp.ExecuteScriptCLI(os.Args[1], os.Args))
|
||||
}
|
||||
|
||||
func BenchmarkHelloWorld(b *testing.B) {
|
||||
if err := frankenphp.Init(frankenphp.WithLogger(zap.NewNop())); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer frankenphp.Shutdown()
|
||||
cwd, _ := os.Getwd()
|
||||
testDataDir := cwd + "/testdata/"
|
||||
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
req := frankenphp.NewRequestWithContext(r, testDataDir, nil)
|
||||
if err := frankenphp.ServeHTTP(w, req); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/index.php", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
handler(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
17
internal/testcli/main.go
Normal file
17
internal/testcli/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/dunglas/frankenphp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) <= 1 {
|
||||
log.Println("Usage: testcli script.php")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.Exit(frankenphp.ExecuteScriptCLI(os.Args[1], os.Args))
|
||||
}
|
||||
6
testdata/command.php
vendored
Normal file
6
testdata/command.php
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
var_dump($argv, $_SERVER);
|
||||
echo "From the CLI\n";
|
||||
|
||||
exit(3);
|
||||
Reference in New Issue
Block a user