mirror of
https://github.com/dunglas/frankenphp.git
synced 2025-12-24 13:38:11 +08:00
Merge branch 'main' into feat/task-threads
# Conflicts: # types_test.go
This commit is contained in:
1
.gitleaksignore
Normal file
1
.gitleaksignore
Normal file
@@ -0,0 +1 @@
|
||||
/github/workspace/docs/mercure.md:jwt:65
|
||||
21
caddy/app.go
21
caddy/app.go
@@ -44,7 +44,7 @@ type FrankenPHPApp struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
var iniError = errors.New("'php_ini' must be in the format: php_ini \"<key>\" \"<value>\"")
|
||||
var iniError = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
|
||||
|
||||
// CaddyModule returns the Caddy module information.
|
||||
func (f FrankenPHPApp) CaddyModule() caddy.ModuleInfo {
|
||||
@@ -205,7 +205,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
||||
|
||||
v, err := time.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return errors.New("max_wait_time must be a valid duration (example: 10s)")
|
||||
return d.Err("max_wait_time must be a valid duration (example: 10s)")
|
||||
}
|
||||
|
||||
f.MaxWaitTime = v
|
||||
@@ -213,14 +213,14 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
||||
parseIniLine := func(d *caddyfile.Dispenser) error {
|
||||
key := d.Val()
|
||||
if !d.NextArg() {
|
||||
return iniError
|
||||
return d.WrapErr(iniError)
|
||||
}
|
||||
if f.PhpIni == nil {
|
||||
f.PhpIni = make(map[string]string)
|
||||
}
|
||||
f.PhpIni[key] = d.Val()
|
||||
if d.NextArg() {
|
||||
return iniError
|
||||
return d.WrapErr(iniError)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -237,7 +237,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
||||
|
||||
if !isBlock {
|
||||
if !d.NextArg() {
|
||||
return iniError
|
||||
return d.WrapErr(iniError)
|
||||
}
|
||||
err := parseIniLine(d)
|
||||
if err != nil {
|
||||
@@ -261,12 +261,12 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
||||
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
|
||||
}
|
||||
if strings.HasPrefix(wc.Name, "m#") {
|
||||
return fmt.Errorf(`global worker names must not start with "m#": %q`, wc.Name)
|
||||
return d.Errf(`global worker names must not start with "m#": %q`, wc.Name)
|
||||
}
|
||||
// check for duplicate workers
|
||||
for _, existingWorker := range f.Workers {
|
||||
if existingWorker.FileName == wc.FileName {
|
||||
return fmt.Errorf("global workers must not have duplicate filenames: %q", wc.FileName)
|
||||
return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
||||
}
|
||||
|
||||
if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads {
|
||||
return errors.New(`"max_threads"" must be greater than or equal to "num_threads"`)
|
||||
return d.Err(`"max_threads"" must be greater than or equal to "num_threads"`)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -297,3 +297,8 @@ func parseGlobalOption(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
Value: caddyconfig.JSON(app, nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ caddy.App = (*FrankenPHPApp)(nil)
|
||||
_ caddy.Provisioner = (*FrankenPHPApp)(nil)
|
||||
)
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -35,12 +33,3 @@ func init() {
|
||||
func wrongSubDirectiveError(module string, allowedDriectives string, wrongValue string) error {
|
||||
return fmt.Errorf("unknown '%s' subdirective: '%s' (allowed directives are: %s)", module, wrongValue, allowedDriectives)
|
||||
}
|
||||
|
||||
// Interface guards
|
||||
var (
|
||||
_ caddy.App = (*FrankenPHPApp)(nil)
|
||||
_ caddy.Provisioner = (*FrankenPHPApp)(nil)
|
||||
_ caddy.Provisioner = (*FrankenPHPModule)(nil)
|
||||
_ caddyhttp.MiddlewareHandler = (*FrankenPHPModule)(nil)
|
||||
_ caddyfile.Unmarshaler = (*FrankenPHPModule)(nil)
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ func init() {
|
||||
caddycmd.RegisterCommand(caddycmd.Command{
|
||||
Name: "extension-init",
|
||||
Usage: "go_extension.go [--verbose]",
|
||||
Short: "(Experimental) Initializes a PHP extension from a Go file",
|
||||
Short: "Initializes a PHP extension from a Go file (EXPERIMENTAL)",
|
||||
Long: `
|
||||
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.`,
|
||||
CobraFunc: func(cmd *cobra.Command) {
|
||||
|
||||
@@ -71,9 +71,8 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
|
||||
}
|
||||
|
||||
for i, wc := range f.Workers {
|
||||
|
||||
// make the file path absolute from the public directory
|
||||
// this can only be done if the root is definied inside php_server
|
||||
// this can only be done if the root is defined inside php_server
|
||||
if !filepath.IsAbs(wc.FileName) && f.Root != "" {
|
||||
wc.FileName = filepath.Join(f.Root, wc.FileName)
|
||||
}
|
||||
@@ -602,7 +601,7 @@ func prependWorkerRoutes(routes caddyhttp.RouteList, h httpcaddyfile.Helper, f F
|
||||
// forward matching routes to the PHP handler
|
||||
routes = append(routes, caddyhttp.Route{
|
||||
MatcherSetsRaw: []caddy.ModuleMap{
|
||||
caddy.ModuleMap{"path": h.JSON(allWorkerMatches)},
|
||||
{"path": h.JSON(allWorkerMatches)},
|
||||
},
|
||||
HandlersRaw: []json.RawMessage{
|
||||
caddyconfig.JSONModuleObject(f, "handler", "php", nil),
|
||||
@@ -611,3 +610,10 @@ func prependWorkerRoutes(routes caddyhttp.RouteList, h httpcaddyfile.Helper, f F
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
// Interface guards
|
||||
var (
|
||||
_ caddy.Provisioner = (*FrankenPHPModule)(nil)
|
||||
_ caddyhttp.MiddlewareHandler = (*FrankenPHPModule)(nil)
|
||||
_ caddyfile.Unmarshaler = (*FrankenPHPModule)(nil)
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) {
|
||||
}
|
||||
|
||||
if d.NextArg() {
|
||||
return wc, errors.New(`FrankenPHP: too many "worker" arguments: ` + d.Val())
|
||||
return wc, d.Errf(`FrankenPHP: too many "worker" arguments: %s`, d.Val())
|
||||
}
|
||||
|
||||
for d.NextBlock(1) {
|
||||
|
||||
107
docs/config.md
107
docs/config.md
@@ -1,17 +1,38 @@
|
||||
# Configuration
|
||||
|
||||
FrankenPHP, Caddy as well as the Mercure and Vulcain modules can be configured using [the formats supported by Caddy](https://caddyserver.com/docs/getting-started#your-first-config).
|
||||
FrankenPHP, Caddy as well as the [Mercure](mercure.md) and [Vulcain](https://vulcain.rocks) modules can be configured using [the formats supported by Caddy](https://caddyserver.com/docs/getting-started#your-first-config).
|
||||
|
||||
In [the Docker images](docker.md), the `Caddyfile` is located at `/etc/frankenphp/Caddyfile`.
|
||||
The static binary will also look for the `Caddyfile` in the directory where the `frankenphp run` command is executed.
|
||||
The most common format is the `Caddyfile`, which is a simple, human-readable text format.
|
||||
By default, FrankenPHP will look for a `Caddyfile` in the current directory.
|
||||
You can specify a custom path with the `-c` or `--config` option.
|
||||
|
||||
A minimal `Caddyfile` to serve a PHP application is shown below:
|
||||
|
||||
```caddyfile
|
||||
# The hostname to respond to
|
||||
localhost
|
||||
|
||||
# Optionaly, the directory to serve files from, otherwise defaults to the current directory
|
||||
#root public/
|
||||
php_server
|
||||
```
|
||||
|
||||
A more advanced `Caddyfile` enabling more features and providing convenient environment variables is provided [in the FrankenPHP repository](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile),
|
||||
and with Docker images.
|
||||
|
||||
PHP itself can be configured [using a `php.ini` file](https://www.php.net/manual/en/configuration.file.php).
|
||||
|
||||
Depending on your installation method, the PHP interpreter will look for configuration files in locations described below.
|
||||
Depending on your installation method, FrankenPHP and the PHP interpreter will look for configuration files in locations described below.
|
||||
|
||||
## Docker
|
||||
|
||||
FrankenPHP:
|
||||
|
||||
- `/etc/frankenphp/Caddyfile`: the main configuration file
|
||||
- `/etc/frankenphp/caddy.d/*.caddy`: additional configuration files that are loaded automatically
|
||||
|
||||
PHP:
|
||||
|
||||
- `php.ini`: `/usr/local/etc/php/php.ini` (no `php.ini` is provided by default)
|
||||
- additional configuration files: `/usr/local/etc/php/conf.d/*.ini`
|
||||
- PHP extensions: `/usr/local/lib/php/extensions/no-debug-zts-<YYYYMMDD>/`
|
||||
@@ -29,12 +50,25 @@ RUN cp $PHP_INI_DIR/php.ini-development $PHP_INI_DIR/php.ini
|
||||
|
||||
## RPM and Debian packages
|
||||
|
||||
FrankenPHP:
|
||||
|
||||
- `/etc/frankenphp/Caddyfile`: the main configuration file
|
||||
- `/etc/frankenphp/caddy.d/*.caddy`: additional configuration files that are loaded automatically
|
||||
|
||||
PHP:
|
||||
|
||||
- `php.ini`: `/etc/frankenphp/php.ini` (a `php.ini` file with production presets is provided by default)
|
||||
- additional configuration files: `/etc/frankenphp/php.d/*.ini`
|
||||
- PHP extensions: `/usr/lib/frankenphp/modules/`
|
||||
|
||||
## Static binary
|
||||
|
||||
FrankenPHP:
|
||||
|
||||
- In the current working directory: `Caddyfile`
|
||||
|
||||
PHP:
|
||||
|
||||
- `php.ini`: The directory in which `frankenphp run` or `frankenphp php-server` is executed, then `/etc/frankenphp/php.ini`
|
||||
- additional configuration files: `/etc/frankenphp/php.d/*.ini`
|
||||
- PHP extensions: cannot be loaded, bundle them in the binary itself
|
||||
@@ -229,34 +263,6 @@ and otherwise forward the request to the worker matching the path pattern.
|
||||
}
|
||||
```
|
||||
|
||||
### Full Duplex (HTTP/1)
|
||||
|
||||
When using HTTP/1.x, it may be desirable to enable full-duplex mode to allow writing a response before the entire body
|
||||
has been read. (for example: WebSocket, Server-Sent Events, etc.)
|
||||
|
||||
This is an opt-in configuration that needs to be added to the global options in the `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
{
|
||||
servers {
|
||||
enable_full_duplex
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
>
|
||||
> Enabling this option may cause old HTTP/1.x clients that don't support full-duplex to deadlock.
|
||||
> This can also be configured using the `CADDY_GLOBAL_OPTIONS` environment config:
|
||||
|
||||
```sh
|
||||
CADDY_GLOBAL_OPTIONS="servers {
|
||||
enable_full_duplex
|
||||
}"
|
||||
```
|
||||
|
||||
You can find more information about this setting in the [Caddy documentation](https://caddyserver.com/docs/caddyfile/options#enable-full-duplex).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The following environment variables can be used to inject Caddy directives in the `Caddyfile` without modifying it:
|
||||
@@ -293,6 +299,43 @@ You can also change the PHP configuration using the `php_ini` directive in the `
|
||||
}
|
||||
```
|
||||
|
||||
### Disabling HTTPS
|
||||
|
||||
By default, FrankenPHP will automatically enable HTTPS using for all the hostnames, including `localhost`.
|
||||
If you want to disable HTTPS (for example in a development environment), you can set the `SERVER_NAME` environment variable to `http://` or `:80`:
|
||||
|
||||
Alternatively, you can use all other methods described in the [Caddy documentation](https://caddyserver.com/docs/automatic-https#activation).
|
||||
|
||||
If you want to use HTTPS with the `127.0.0.1` IP address instead of the `localhost` hostname, please read the [known issues](known-issues.md#using-https127001-with-docker) section.
|
||||
|
||||
### Full Duplex (HTTP/1)
|
||||
|
||||
When using HTTP/1.x, it may be desirable to enable full-duplex mode to allow writing a response before the entire body
|
||||
has been read. (for example: [Mercure](mercure.md), WebSocket, Server-Sent Events, etc.)
|
||||
|
||||
This is an opt-in configuration that needs to be added to the global options in the `Caddyfile`:
|
||||
|
||||
```caddyfile
|
||||
{
|
||||
servers {
|
||||
enable_full_duplex
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
>
|
||||
> Enabling this option may cause old HTTP/1.x clients that don't support full-duplex to deadlock.
|
||||
> This can also be configured using the `CADDY_GLOBAL_OPTIONS` environment config:
|
||||
|
||||
```sh
|
||||
CADDY_GLOBAL_OPTIONS="servers {
|
||||
enable_full_duplex
|
||||
}"
|
||||
```
|
||||
|
||||
You can find more information about this setting in the [Caddy documentation](https://caddyserver.com/docs/caddyfile/options#enable-full-duplex).
|
||||
|
||||
## Enable the Debug Mode
|
||||
|
||||
When using the Docker image, set the `CADDY_GLOBAL_OPTIONS` environment variable to `debug` to enable the debug mode:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Building Custom Docker Image
|
||||
|
||||
[FrankenPHP Docker images](https://hub.docker.com/r/dunglas/frankenphp) are based on [official PHP images](https://hub.docker.com/_/php/). Debian and Alpine Linux variants are provided for popular architectures. Debian variants are recommended.
|
||||
[FrankenPHP Docker images](https://hub.docker.com/r/dunglas/frankenphp) are based on [official PHP images](https://hub.docker.com/_/php/).
|
||||
Debian and Alpine Linux variants are provided for popular architectures.
|
||||
Debian variants are recommended.
|
||||
|
||||
Variants for PHP 8.2, 8.3 and 8.4 are provided.
|
||||
|
||||
@@ -28,6 +30,11 @@ docker build -t my-php-app .
|
||||
docker run -it --rm --name my-running-app my-php-app
|
||||
```
|
||||
|
||||
## How to Tweak the Configuration
|
||||
|
||||
For convenience, [a default `Caddyfile`](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile) containing
|
||||
useful environment variables is provided in the image.
|
||||
|
||||
## How to Install More PHP Extensions
|
||||
|
||||
The [`docker-php-extension-installer`](https://github.com/mlocati/docker-php-extension-installer) script is provided in the base image.
|
||||
|
||||
@@ -33,7 +33,7 @@ As covered in the manual implementation section below as well, you need to [get
|
||||
The first step to writing a PHP extension in Go is to create a new Go module. You can use the following command for this:
|
||||
|
||||
```console
|
||||
go mod init example.com/example
|
||||
go mod init example.com/example
|
||||
```
|
||||
|
||||
The second step is to [get the PHP sources](https://www.php.net/downloads.php) for the next steps. Once you have them, decompress them into the directory of your choice, not inside your Go module:
|
||||
@@ -49,10 +49,11 @@ Everything is now setup to write your native function in Go. Create a new file n
|
||||
```go
|
||||
package example
|
||||
|
||||
// #include <Zend/zend_types.h>
|
||||
// #include <Zend/zend_types.h>
|
||||
import "C"
|
||||
import (
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/dunglas/frankenphp"
|
||||
)
|
||||
@@ -126,14 +127,17 @@ package example
|
||||
import "C"
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
|
||||
"github.com/dunglas/frankenphp"
|
||||
)
|
||||
|
||||
// export_php:function process_data_ordered(array $input): array
|
||||
func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
|
||||
// Convert PHP associative array to Go while keeping the order
|
||||
associativeArray := frankenphp.GoAssociativeArray(unsafe.Pointer(arr))
|
||||
associativeArray, err := frankenphp.GoAssociativeArray[any](unsafe.Pointer(arr))
|
||||
if err != nil {
|
||||
// handle error
|
||||
}
|
||||
|
||||
// loop over the entries in order
|
||||
for _, key := range associativeArray.Order {
|
||||
@@ -143,8 +147,8 @@ func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
|
||||
|
||||
// return an ordered array
|
||||
// if 'Order' is not empty, only the key-value pairs in 'Order' will be respected
|
||||
return frankenphp.PHPAssociativeArray(frankenphp.AssociativeArray{
|
||||
Map: map[string]any{
|
||||
return frankenphp.PHPAssociativeArray[string](frankenphp.AssociativeArray[string]{
|
||||
Map: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
@@ -156,7 +160,10 @@ func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
|
||||
func process_data_unordered_map(arr *C.zval) unsafe.Pointer {
|
||||
// Convert PHP associative array to a Go map without keeping the order
|
||||
// ignoring the order will be more performant
|
||||
goMap := frankenphp.GoMap(unsafe.Pointer(arr))
|
||||
goMap, err := frankenphp.GoMap[any](unsafe.Pointer(arr))
|
||||
if err != nil {
|
||||
// handle error
|
||||
}
|
||||
|
||||
// loop over the entries in no specific order
|
||||
for key, value := range goMap {
|
||||
@@ -164,7 +171,7 @@ func process_data_unordered_map(arr *C.zval) unsafe.Pointer {
|
||||
}
|
||||
|
||||
// return an unordered array
|
||||
return frankenphp.PHPMap(map[string]any{
|
||||
return frankenphp.PHPMap(map[string]string {
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
})
|
||||
@@ -173,7 +180,10 @@ func process_data_unordered_map(arr *C.zval) unsafe.Pointer {
|
||||
// export_php:function process_data_packed(array $input): array
|
||||
func process_data_packed(arr *C.zval) unsafe.Pointer {
|
||||
// Convert PHP packed array to Go
|
||||
goSlice := frankenphp.GoPackedArray(unsafe.Pointer(arr), false)
|
||||
goSlice, err := frankenphp.GoPackedArray(unsafe.Pointer(arr), false)
|
||||
if err != nil {
|
||||
// handle error
|
||||
}
|
||||
|
||||
// loop over the slice in order
|
||||
for index, value := range goSlice {
|
||||
@@ -181,7 +191,7 @@ func process_data_packed(arr *C.zval) unsafe.Pointer {
|
||||
}
|
||||
|
||||
// return a packed array
|
||||
return frankenphp.PHPackedArray([]any{"value1", "value2", "value3"})
|
||||
return frankenphp.PHPPackedArray([]string{"value1", "value2", "value3"})
|
||||
}
|
||||
```
|
||||
|
||||
@@ -790,7 +800,7 @@ import "C"
|
||||
import (
|
||||
"unsafe"
|
||||
"strings"
|
||||
|
||||
|
||||
"github.com/dunglas/frankenphp"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Configuration
|
||||
# Configuration
|
||||
|
||||
FrankenPHP, Caddy ainsi que les modules Mercure et Vulcain peuvent être configurés en utilisant [les formats pris en charge par Caddy](https://caddyserver.com/docs/getting-started#your-first-config).
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ Il y a deux choses importantes à noter ici :
|
||||
- Une directive `//export_php:function` définit la signature de la fonction en PHP. C'est ainsi que le générateur sait comment générer la fonction PHP avec les bons paramètres et le bon type de retour ;
|
||||
- La fonction doit retourner un `unsafe.Pointer`. FrankenPHP fournit une API pour vous aider avec le jonglage de types entre C et Go.
|
||||
|
||||
Alors que le premier point parle de lui-même, le second peut être plus difficile à appréhender. Plongeons plus profondément dans la jonglage de types dans la section suivante.
|
||||
Alors que le premier point parle de lui-même, le second peut être plus difficile à appréhender. Plongeons plus profondément dans le jonglage de types dans la section suivante.
|
||||
|
||||
### Jonglage de Types
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ The following extensions are known not to be compatible with FrankenPHP:
|
||||
|
||||
The following extensions have known bugs and unexpected behaviors when used with FrankenPHP:
|
||||
|
||||
| Name | Problem |
|
||||
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [ext-openssl](https://www.php.net/manual/en/book.openssl.php) | When using a static build of FrankenPHP (built with the musl libc), the OpenSSL extension may crash under heavy loads. A workaround is to use a dynamically linked build (like the one used in Docker images). This bug is [being tracked by PHP](https://github.com/php/php-src/issues/13648). |
|
||||
| Name | Problem |
|
||||
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [ext-openssl](https://www.php.net/manual/en/book.openssl.php) | When using musl libc, the OpenSSL extension may crash under heavy loads. The problem doesn't occur when using the more popular GNU libc. This bug is [being tracked by PHP](https://github.com/php/php-src/issues/13648). |
|
||||
|
||||
## get_browser
|
||||
|
||||
@@ -23,7 +23,11 @@ The [get_browser()](https://www.php.net/manual/en/function.get-browser.php) func
|
||||
|
||||
## Standalone Binary and Alpine-based Docker Images
|
||||
|
||||
The standalone binary and Alpine-based Docker images (`dunglas/frankenphp:*-alpine`) use [musl libc](https://musl.libc.org/) instead of [glibc and friends](https://www.etalabs.net/compare_libcs.html), to keep a smaller binary size. This may lead to some compatibility issues. In particular, the glob flag `GLOB_BRACE` is [not available](https://www.php.net/manual/en/function.glob.php)
|
||||
The fully binary and Alpine-based Docker images (`dunglas/frankenphp:*-alpine`) use [musl libc](https://musl.libc.org/) instead of [glibc and friends](https://www.etalabs.net/compare_libcs.html), to keep a smaller binary size.
|
||||
This may lead to some compatibility issues.
|
||||
In particular, the glob flag `GLOB_BRACE` is [not available](https://www.php.net/manual/en/function.glob.php)
|
||||
|
||||
Prefer using the GNU variant of the static binary and Debian-based Docker images if you encounter issues.
|
||||
|
||||
## Using `https://127.0.0.1` with Docker
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ The `octane:frankenphp` command can take the following options:
|
||||
> [!TIP]
|
||||
> To get structured JSON logs (useful when using log analytics solutions), explicitly the pass `--log-level` option.
|
||||
|
||||
See also [how to use Mercure with Octane](#mercure-support).
|
||||
|
||||
Learn more about [Laravel Octane in its official documentation](https://laravel.com/docs/octane).
|
||||
|
||||
## Laravel Apps As Standalone Binaries
|
||||
@@ -166,6 +168,34 @@ This is not suitable for embedded applications, as each new version will be extr
|
||||
|
||||
Set the `LARAVEL_STORAGE_PATH` environment variable (for example, in your `.env` file) or call the `Illuminate\Foundation\Application::useStoragePath()` method to use a directory outside the temporary directory.
|
||||
|
||||
### Mercure Support
|
||||
|
||||
[Mercure](https://mercure.rocks) is a great way to add real-time capabilities to your Laravel apps.
|
||||
FrankenPHP includes [Mercure support out of the box](mercure.md).
|
||||
|
||||
If you are not using [Octane](#laravel-octane), see [the Mercure documentation entry](mercure.md).
|
||||
|
||||
If you are using Octane, you can use enable Mercure support by adding the following lines to your `config/octane.php` file:
|
||||
|
||||
```php
|
||||
// ...
|
||||
|
||||
return [
|
||||
// ...
|
||||
|
||||
'mercure' => [
|
||||
'anonymous' => true,
|
||||
'publisher_jwt' => '!ChangeThisMercureHubJWTSecretKey!',
|
||||
'subscriber_jwt' => '!ChangeThisMercureHubJWTSecretKey!',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
You can use [all directives supported by Mercure](https://mercure.rocks/docs/hub/config#directives) in this array.
|
||||
|
||||
To publish and subscribe to updates, we recommend using the [Laravel Mercure Broadcaster](https://github.com/mvanduijker/laravel-mercure-broadcaster) library.
|
||||
Alternatively, see [the Mercure documentation](mercure.md) to do it in pure PHP and JavaScript.
|
||||
|
||||
### Running Octane With Standalone Binaries
|
||||
|
||||
It's even possible to package Laravel Octane apps as standalone binaries!
|
||||
|
||||
121
docs/mercure.md
121
docs/mercure.md
@@ -3,13 +3,124 @@
|
||||
FrankenPHP comes with a built-in [Mercure](https://mercure.rocks) hub!
|
||||
Mercure allows you to push real-time events to all the connected devices: they will receive a JavaScript event instantly.
|
||||
|
||||
No JS library or SDK is required!
|
||||
It's a convenient alternative to WebSockets that is simple to use and is natively supported by all modern web browsers!
|
||||
|
||||

|
||||
|
||||
To enable the Mercure hub, update the `Caddyfile` as described [on Mercure's site](https://mercure.rocks/docs/hub/config).
|
||||
## Enabling Mercure
|
||||
|
||||
The path of the Mercure hub is `/.well-known/mercure`.
|
||||
When running FrankenPHP inside Docker, the full send URL would look like `http://php/.well-known/mercure` (with `php` being the container's name running FrankenPHP).
|
||||
Mercure support is disabled by default.
|
||||
Here is a minimal example of a `Caddyfile` enabling both FrankenPHP and the Mercure hub:
|
||||
|
||||
To push Mercure updates from your code, we recommend the [Symfony Mercure Component](https://symfony.com/components/Mercure) (you don't need the Symfony full-stack framework to use it).
|
||||
```caddyfile
|
||||
# The hostname to respond to
|
||||
localhost
|
||||
|
||||
mercure {
|
||||
# The secret key used to sign the JWT tokens for publishers
|
||||
publisher_jwt !ChangeThisMercureHubJWTSecretKey!
|
||||
# Allows anonymous subscribers (without JWT)
|
||||
anonymous
|
||||
}
|
||||
|
||||
root public/
|
||||
php_server
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> The [sample `Caddyfile`](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile)
|
||||
> provided by [the Docker images](docker.md) already includes a commented Mercure configuration
|
||||
> with convenient environment variables to configure it.
|
||||
>
|
||||
> Uncomment the Mercure section in `/etc/frankenphp/Caddyfile` to enable it.
|
||||
|
||||
## Subscribing to Updates
|
||||
|
||||
By default, the Mercure hub is available on the `/.well-known/mercure` path of your FrankenPHP server.
|
||||
To subscribe to updates, use the native [`EventSource`](https://developer.mozilla.org/docs/Web/API/EventSource) JavaScript class:
|
||||
|
||||
```html
|
||||
<!-- public/index.html -->
|
||||
<!doctype html>
|
||||
<title>Mercure Example</title>
|
||||
<script>
|
||||
const eventSource = new EventSource("/.well-known/mercure?topic=my-topic");
|
||||
eventSource.onmessage = function (event) {
|
||||
console.log("New message:", event.data);
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## Publishing Updates
|
||||
|
||||
### Using `file_put_contents()`
|
||||
|
||||
To dispatch an update to connected subscribers, send an authenticated POST request to the Mercure hub with the `topic` and `data` parameters:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// public/publish.php
|
||||
|
||||
const JWT = 'eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.PXwpfIGng6KObfZlcOXvcnWCJOWTFLtswGI5DZuWSK4';
|
||||
|
||||
$updateID = file_put_contents('https://localhost/.well-known/mercure', context: stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-type: application/x-www-form-urlencoded\r\nAuthorization: Bearer " . JWT,
|
||||
'content' => http_build_query([
|
||||
'topic' => 'my-topic',
|
||||
'data' => json_encode(['key' => 'value']),
|
||||
]),
|
||||
]]));
|
||||
|
||||
// Write to FrankenPHP's logs
|
||||
error_log("update $updateID published", 4);
|
||||
```
|
||||
|
||||
The key passed as parameter of the `mercure.publisher_jwt` option in the `Caddyfile` must used to sign the JWT token used in the `Authorization` header.
|
||||
|
||||
The JWT must include a `mercure` claim with a `publish` permission for the topics you want to publish to.
|
||||
See [the Mercure documentation](https://mercure.rocks/spec#publishers) about authorization.
|
||||
|
||||
To generate your own tokens, you can use [this jwt.io link](https://www.jwt.io/#token=eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.PXwpfIGng6KObfZlcOXvcnWCJOWTFLtswGI5DZuWSK4),
|
||||
but for production apps, it's recommended to use short-lived tokens generated aerodynamically using with a trusted [JWT library](https://www.jwt.io/libraries?programming_language=php).
|
||||
|
||||
### Using Symfony Mercure
|
||||
|
||||
Alternatively, you can use the [Symfony Mercure Component](https://symfony.com/components/Mercure), a standalone PHP library.
|
||||
|
||||
This library handled the JWT generation, update publishing as well as cookie-based authorization for subscribers.
|
||||
|
||||
First, install the library using Composer:
|
||||
|
||||
```console
|
||||
composer require symfony/mercure lcobucci/jwt
|
||||
```
|
||||
|
||||
Then, you can use it like this:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// public/publish.php
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
const JWT_SECRET = '!ChangeThisMercureHubJWTSecretKey!'; // Must be the same as mercure.publisher_jwt in Caddyfile
|
||||
|
||||
// Set up the JWT token provider
|
||||
$jwFactory = new \Symfony\Component\Mercure\Jwt\LcobucciFactory(JWT_SECRET);
|
||||
$provider = new \Symfony\Component\Mercure\Jwt\FactoryTokenProvider($jwFactory, publish: ['*']);
|
||||
|
||||
$hub = new \Symfony\Component\Mercure\Hub('https://localhost/.well-known/mercure', $provider);
|
||||
// Serialize the update, and dispatch it to the hub, that will broadcast it to the clients
|
||||
$updateID = $hub->publish(new \Symfony\Component\Mercure\Update('my-topic', json_encode(['key' => 'value'])));
|
||||
|
||||
// Write to FrankenPHP's logs
|
||||
error_log("update $updateID published", 4);
|
||||
```
|
||||
|
||||
Mercure is also natively supported by:
|
||||
|
||||
- [Laravel](laravel.md#mercure-support)
|
||||
- [Symfony](https://symfony.com/doc/current/mercure.html)
|
||||
- [API Platform](https://api-platform.com/docs/core/mercure/)
|
||||
|
||||
@@ -498,9 +498,18 @@ func go_write_headers(threadIndex C.uintptr_t, status C.int, headers *C.zend_lli
|
||||
current = current.next
|
||||
}
|
||||
|
||||
fc.responseWriter.WriteHeader(int(status))
|
||||
goStatus := int(status)
|
||||
|
||||
if status >= 100 && status < 200 {
|
||||
// go panics on invalid status code
|
||||
// https://github.com/golang/go/blob/9b8742f2e79438b9442afa4c0a0139d3937ea33f/src/net/http/server.go#L1162
|
||||
if goStatus < 100 || goStatus > 999 {
|
||||
logger.Warn(fmt.Sprintf("Invalid response status code %v", goStatus))
|
||||
goStatus = 500
|
||||
}
|
||||
|
||||
fc.responseWriter.WriteHeader(goStatus)
|
||||
|
||||
if goStatus >= 100 && goStatus < 200 {
|
||||
// Clear headers, it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses
|
||||
h := fc.responseWriter.Header()
|
||||
for k := range h {
|
||||
|
||||
@@ -313,7 +313,7 @@ func TestCFileSpecialCharacters(t *testing.T) {
|
||||
content, err := cGen.buildContent()
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedInclude := "#include \"" + tt.expected + ".h\""
|
||||
expectedInclude := `#include "` + tt.expected + `.h"`
|
||||
assert.Contains(t, content, expectedInclude, "Content should contain include: %s", expectedInclude)
|
||||
})
|
||||
}
|
||||
@@ -424,8 +424,8 @@ func TestCFileConstants(t *testing.T) {
|
||||
},
|
||||
},
|
||||
contains: []string{
|
||||
"REGISTER_LONG_CONSTANT(\"GLOBAL_INT\", 42, CONST_CS | CONST_PERSISTENT);",
|
||||
"REGISTER_STRING_CONSTANT(\"GLOBAL_STRING\", \"test\", CONST_CS | CONST_PERSISTENT);",
|
||||
`REGISTER_LONG_CONSTANT("GLOBAL_INT", 42, CONST_CS | CONST_PERSISTENT);`,
|
||||
`REGISTER_STRING_CONSTANT("GLOBAL_STRING", "test", CONST_CS | CONST_PERSISTENT);`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e
|
||||
func determineConstantType(value string) phpType {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
|
||||
if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) ||
|
||||
(strings.HasPrefix(value, "`") && strings.HasSuffix(value, "`")) {
|
||||
return phpString
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ func TestConstantParserTypeDetection(t *testing.T) {
|
||||
value string
|
||||
expectedType phpType
|
||||
}{
|
||||
{"string with double quotes", "\"hello world\"", phpString},
|
||||
{"string with double quotes", `"hello world"`, phpString},
|
||||
{"string with backticks", "`hello world`", phpString},
|
||||
{"boolean true", "true", phpBool},
|
||||
{"boolean false", "false", phpBool},
|
||||
@@ -443,13 +443,13 @@ func TestConstantParserDeclRegex(t *testing.T) {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"const MyConst = \"value\"", true, "MyConst", "\"value\""},
|
||||
{`const MyConst = "value"`, true, "MyConst", `"value"`},
|
||||
{"const IntConst = 42", true, "IntConst", "42"},
|
||||
{"const BoolConst = true", true, "BoolConst", "true"},
|
||||
{"const IotaConst = iota", true, "IotaConst", "iota"},
|
||||
{"const ComplexValue = someFunction()", true, "ComplexValue", "someFunction()"},
|
||||
{"const SpacedName = \"with spaces\"", true, "SpacedName", "\"with spaces\""},
|
||||
{"var notAConst = \"value\"", false, "", ""},
|
||||
{`const SpacedName = "with spaces"`, true, "SpacedName", `"with spaces"`},
|
||||
{`var notAConst = "value"`, false, "", ""},
|
||||
{"const", false, "", ""},
|
||||
{"const =", false, "", ""},
|
||||
}
|
||||
@@ -518,10 +518,10 @@ func TestPHPConstantCValue(t *testing.T) {
|
||||
name: "string constant",
|
||||
constant: phpConstant{
|
||||
Name: "StringConst",
|
||||
Value: "\"hello\"",
|
||||
Value: `"hello"`,
|
||||
PhpType: phpString,
|
||||
},
|
||||
expected: "\"hello\"", // strings should remain unchanged
|
||||
expected: `"hello"`, // strings should remain unchanged
|
||||
},
|
||||
{
|
||||
name: "boolean constant",
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestStubGenerator_BuildContent(t *testing.T) {
|
||||
contains: []string{
|
||||
"<?php",
|
||||
"/** @generate-class-entries */",
|
||||
"const GLOBAL_CONST = \"test\";",
|
||||
`const GLOBAL_CONST = "test";`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ func NamespacedName(namespace, name string) string {
|
||||
if namespace == "" {
|
||||
return name
|
||||
}
|
||||
namespacePart := strings.ReplaceAll(namespace, "\\", "_")
|
||||
namespacePart := strings.ReplaceAll(namespace, `\`, "_")
|
||||
return namespacePart + "_" + name
|
||||
}
|
||||
|
||||
|
||||
@@ -62,12 +62,6 @@ RUN if [ "$(uname -m)" = "aarch64" ]; then \
|
||||
echo "source scl_source enable devtoolset-10" >> /etc/bashrc && \
|
||||
source /etc/bashrc
|
||||
|
||||
# install newer cmake to build some newer libs
|
||||
RUN curl -o cmake.tar.gz -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.4/cmake-3.31.4-linux-$(uname -m).tar.gz && \
|
||||
mkdir /cmake && \
|
||||
tar -xzf cmake.tar.gz -C /cmake --strip-components 1 && \
|
||||
rm cmake.tar.gz
|
||||
|
||||
# install build essentials
|
||||
RUN yum install -y \
|
||||
perl \
|
||||
@@ -95,6 +89,15 @@ RUN yum install -y \
|
||||
ln -sf /usr/local/bin/make /usr/bin/make && \
|
||||
cd .. && \
|
||||
rm -Rf make* && \
|
||||
curl -o cmake.tar.gz -fsSL https://github.com/Kitware/CMake/releases/download/v4.1.2/cmake-4.1.2-linux-$(uname -m).tar.gz && \
|
||||
mkdir /cmake && \
|
||||
tar -xzf cmake.tar.gz -C /cmake --strip-components 1 && \
|
||||
rm cmake.tar.gz && \
|
||||
curl -fsSL -o patchelf.tar.gz https://github.com/NixOS/patchelf/releases/download/0.18.0/patchelf-0.18.0-$(uname -m).tar.gz && \
|
||||
mkdir -p /patchelf && \
|
||||
tar -xzf patchelf.tar.gz -C /patchelf --strip-components=1 && \
|
||||
cp /patchelf/bin/patchelf /usr/bin/ && \
|
||||
rm patchelf.tar.gz && \
|
||||
if [ "$(uname -m)" = "aarch64" ]; then \
|
||||
GO_ARCH="arm64" ; \
|
||||
else \
|
||||
|
||||
@@ -4,6 +4,7 @@ package frankenphp
|
||||
import "C"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -246,7 +247,12 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, retval *C.zval
|
||||
thread := phpThreads[threadIndex]
|
||||
fc := thread.getRequestContext()
|
||||
if retval != nil {
|
||||
fc.handlerReturn = GoValue(unsafe.Pointer(retval))
|
||||
r, err := GoValue[any](unsafe.Pointer(retval))
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("cannot convert return value: %s", err))
|
||||
}
|
||||
|
||||
fc.handlerReturn = r
|
||||
}
|
||||
|
||||
fc.closeContext()
|
||||
|
||||
260
types.go
260
types.go
@@ -5,11 +5,17 @@ package frankenphp
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type toZval interface {
|
||||
toZval() *C.zval
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: GoString copies a zend_string to a Go string.
|
||||
func GoString(s unsafe.Pointer) string {
|
||||
if s == nil {
|
||||
@@ -39,37 +45,44 @@ func PHPString(s string, persistent bool) unsafe.Pointer {
|
||||
}
|
||||
|
||||
// AssociativeArray represents a PHP array with ordered key-value pairs
|
||||
type AssociativeArray struct {
|
||||
Map map[string]any
|
||||
type AssociativeArray[T any] struct {
|
||||
Map map[string]T
|
||||
Order []string
|
||||
}
|
||||
|
||||
func (a AssociativeArray[T]) toZval() *C.zval {
|
||||
return (*C.zval)(PHPAssociativeArray[T](a))
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: GoAssociativeArray converts a zend_array to a Go AssociativeArray
|
||||
func GoAssociativeArray(arr unsafe.Pointer) AssociativeArray {
|
||||
entries, order := goArray(arr, true)
|
||||
return AssociativeArray{entries, order}
|
||||
func GoAssociativeArray[T any](arr unsafe.Pointer) (AssociativeArray[T], error) {
|
||||
entries, order, err := goArray[T](arr, true)
|
||||
|
||||
return AssociativeArray[T]{entries, order}, err
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: GoMap converts a zval having a zend_array value to an unordered Go map
|
||||
func GoMap(arr unsafe.Pointer) map[string]any {
|
||||
entries, _ := goArray(arr, false)
|
||||
return entries
|
||||
func GoMap[T any](arr unsafe.Pointer) (map[string]T, error) {
|
||||
entries, _, err := goArray[T](arr, false)
|
||||
|
||||
return entries, err
|
||||
}
|
||||
|
||||
func goArray(arr unsafe.Pointer, ordered bool) (map[string]any, []string) {
|
||||
func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, error) {
|
||||
if arr == nil {
|
||||
panic("received a nil pointer on array conversion")
|
||||
return nil, nil, errors.New("received a nil pointer on array conversion")
|
||||
}
|
||||
|
||||
zval := (*C.zval)(arr)
|
||||
hashTable := (*C.HashTable)(extractZvalValue(zval, C.IS_ARRAY))
|
||||
|
||||
if hashTable == nil {
|
||||
panic("received a *zval that wasn't a HashTable on array conversion")
|
||||
v, err := extractZvalValue(zval, C.IS_ARRAY)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("received a *zval that wasn't a HashTable on array conversion: %w", err)
|
||||
}
|
||||
|
||||
hashTable := (*C.HashTable)(v)
|
||||
|
||||
nNumUsed := hashTable.nNumUsed
|
||||
entries := make(map[string]any, nNumUsed)
|
||||
entries := make(map[string]T, nNumUsed)
|
||||
var order []string
|
||||
if ordered {
|
||||
order = make([]string, 0, nNumUsed)
|
||||
@@ -83,27 +96,42 @@ func goArray(arr unsafe.Pointer, ordered bool) (map[string]any, []string) {
|
||||
v := C.get_ht_packed_data(hashTable, i)
|
||||
if v != nil && C.zval_get_type(v) != C.IS_UNDEF {
|
||||
strIndex := strconv.Itoa(int(i))
|
||||
entries[strIndex] = goValue(v)
|
||||
e, err := goValue[T](v)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
entries[strIndex] = e
|
||||
if ordered {
|
||||
order = append(order, strIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries, order
|
||||
return entries, order, nil
|
||||
}
|
||||
|
||||
var zeroVal T
|
||||
|
||||
for i := C.uint32_t(0); i < nNumUsed; i++ {
|
||||
bucket := C.get_ht_bucket_data(hashTable, i)
|
||||
if bucket == nil || C.zval_get_type(&bucket.val) == C.IS_UNDEF {
|
||||
continue
|
||||
}
|
||||
|
||||
v := goValue(&bucket.val)
|
||||
v, err := goValue[any](&bucket.val)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if bucket.key != nil {
|
||||
keyStr := GoString(unsafe.Pointer(bucket.key))
|
||||
entries[keyStr] = v
|
||||
if v == nil {
|
||||
entries[keyStr] = zeroVal
|
||||
} else {
|
||||
entries[keyStr] = v.(T)
|
||||
}
|
||||
|
||||
if ordered {
|
||||
order = append(order, keyStr)
|
||||
}
|
||||
@@ -113,64 +141,75 @@ func goArray(arr unsafe.Pointer, ordered bool) (map[string]any, []string) {
|
||||
|
||||
// as fallback convert the bucket index to a string key
|
||||
strIndex := strconv.Itoa(int(bucket.h))
|
||||
entries[strIndex] = v
|
||||
entries[strIndex] = v.(T)
|
||||
if ordered {
|
||||
order = append(order, strIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return entries, order
|
||||
return entries, order, nil
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: GoPackedArray converts a zval with a zend_array value to a Go slice
|
||||
func GoPackedArray(arr unsafe.Pointer) []any {
|
||||
func GoPackedArray[T any](arr unsafe.Pointer) ([]T, error) {
|
||||
if arr == nil {
|
||||
panic("GoPackedArray received a nil pointer")
|
||||
return nil, errors.New("GoPackedArray received a nil value")
|
||||
}
|
||||
|
||||
zval := (*C.zval)(arr)
|
||||
hashTable := (*C.HashTable)(extractZvalValue(zval, C.IS_ARRAY))
|
||||
|
||||
if hashTable == nil {
|
||||
panic("GoPackedArray received *zval that wasn't a HashTable")
|
||||
v, err := extractZvalValue(zval, C.IS_ARRAY)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GoPackedArray received *zval that wasn't a HashTable: %w", err)
|
||||
}
|
||||
|
||||
hashTable := (*C.HashTable)(v)
|
||||
|
||||
nNumUsed := hashTable.nNumUsed
|
||||
result := make([]any, 0, nNumUsed)
|
||||
result := make([]T, 0, nNumUsed)
|
||||
|
||||
if htIsPacked(hashTable) {
|
||||
for i := C.uint32_t(0); i < nNumUsed; i++ {
|
||||
v := C.get_ht_packed_data(hashTable, i)
|
||||
if v != nil && C.zval_get_type(v) != C.IS_UNDEF {
|
||||
result = append(result, goValue(v))
|
||||
v, err := goValue[T](v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fallback if ht isn't packed - equivalent to array_values()
|
||||
for i := C.uint32_t(0); i < nNumUsed; i++ {
|
||||
bucket := C.get_ht_bucket_data(hashTable, i)
|
||||
if bucket != nil && C.zval_get_type(&bucket.val) != C.IS_UNDEF {
|
||||
result = append(result, goValue(&bucket.val))
|
||||
v, err := goValue[T](&bucket.val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: PHPMap converts an unordered Go map to a PHP zend_array
|
||||
func PHPMap(arr map[string]any) unsafe.Pointer {
|
||||
return phpArray(arr, nil)
|
||||
func PHPMap[T any](arr map[string]T) unsafe.Pointer {
|
||||
return phpArray[T](arr, nil)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: PHPAssociativeArray converts a Go AssociativeArray to a PHP zval with a zend_array value
|
||||
func PHPAssociativeArray(arr AssociativeArray) unsafe.Pointer {
|
||||
return phpArray(arr.Map, arr.Order)
|
||||
func PHPAssociativeArray[T any](arr AssociativeArray[T]) unsafe.Pointer {
|
||||
return phpArray[T](arr.Map, arr.Order)
|
||||
}
|
||||
|
||||
func phpArray(entries map[string]any, order []string) unsafe.Pointer {
|
||||
func phpArray[T any](entries map[string]T, order []string) unsafe.Pointer {
|
||||
var zendArray *C.HashTable
|
||||
|
||||
if len(order) != 0 {
|
||||
@@ -195,7 +234,7 @@ func phpArray(entries map[string]any, order []string) unsafe.Pointer {
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: PHPPackedArray converts a Go slice to a PHP zval with a zend_array value.
|
||||
func PHPPackedArray(slice []any) unsafe.Pointer {
|
||||
func PHPPackedArray[T any](slice []T) unsafe.Pointer {
|
||||
zendArray := createNewArray((uint32)(len(slice)))
|
||||
for _, val := range slice {
|
||||
zval := phpValue(val)
|
||||
@@ -209,53 +248,116 @@ func PHPPackedArray(slice []any) unsafe.Pointer {
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: GoValue converts a PHP zval to a Go value
|
||||
func GoValue(zval unsafe.Pointer) any {
|
||||
return goValue((*C.zval)(zval))
|
||||
//
|
||||
// Zval having the null, bool, long, double, string and array types are currently supported.
|
||||
// Arrays can curently only be converted to any[] and AssociativeArray[any].
|
||||
// Any other type will cause an error.
|
||||
// More types may be supported in the future.
|
||||
func GoValue[T any](zval unsafe.Pointer) (T, error) {
|
||||
return goValue[T]((*C.zval)(zval))
|
||||
}
|
||||
|
||||
func goValue(zval *C.zval) any {
|
||||
func goValue[T any](zval *C.zval) (res T, err error) {
|
||||
var (
|
||||
resAny any
|
||||
resZero T
|
||||
)
|
||||
t := C.zval_get_type(zval)
|
||||
switch t {
|
||||
case C.IS_NULL:
|
||||
return nil
|
||||
resAny = any(nil)
|
||||
case C.IS_FALSE:
|
||||
return false
|
||||
resAny = any(false)
|
||||
case C.IS_TRUE:
|
||||
return true
|
||||
resAny = any(true)
|
||||
case C.IS_LONG:
|
||||
longPtr := (*C.zend_long)(extractZvalValue(zval, C.IS_LONG))
|
||||
if longPtr != nil {
|
||||
return int64(*longPtr)
|
||||
v, err := extractZvalValue(zval, C.IS_LONG)
|
||||
if err != nil {
|
||||
return resZero, err
|
||||
}
|
||||
|
||||
return int64(0)
|
||||
if v != nil {
|
||||
resAny = any(int64(*(*C.zend_long)(v)))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
resAny = any(int64(0))
|
||||
case C.IS_DOUBLE:
|
||||
doublePtr := (*C.double)(extractZvalValue(zval, C.IS_DOUBLE))
|
||||
if doublePtr != nil {
|
||||
return float64(*doublePtr)
|
||||
v, err := extractZvalValue(zval, C.IS_DOUBLE)
|
||||
if err != nil {
|
||||
return resZero, err
|
||||
}
|
||||
|
||||
return float64(0)
|
||||
if v != nil {
|
||||
resAny = any(float64(*(*C.double)(v)))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
resAny = any(float64(0))
|
||||
case C.IS_STRING:
|
||||
str := (*C.zend_string)(extractZvalValue(zval, C.IS_STRING))
|
||||
if str == nil {
|
||||
return ""
|
||||
v, err := extractZvalValue(zval, C.IS_STRING)
|
||||
if err != nil {
|
||||
return resZero, err
|
||||
}
|
||||
|
||||
return GoString(unsafe.Pointer(str))
|
||||
if v == nil {
|
||||
resAny = any("")
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
resAny = any(GoString(v))
|
||||
case C.IS_ARRAY:
|
||||
hashTable := (*C.HashTable)(extractZvalValue(zval, C.IS_ARRAY))
|
||||
if hashTable != nil && htIsPacked(hashTable) {
|
||||
return GoPackedArray(unsafe.Pointer(zval))
|
||||
v, err := extractZvalValue(zval, C.IS_ARRAY)
|
||||
if err != nil {
|
||||
return resZero, err
|
||||
}
|
||||
|
||||
return GoAssociativeArray(unsafe.Pointer(zval))
|
||||
hashTable := (*C.HashTable)(v)
|
||||
if hashTable != nil && htIsPacked(hashTable) {
|
||||
typ := reflect.TypeOf(res)
|
||||
if typ == nil || typ.Kind() == reflect.Interface && typ.NumMethod() == 0 {
|
||||
r, e := GoPackedArray[any](unsafe.Pointer(zval))
|
||||
if e != nil {
|
||||
return resZero, e
|
||||
}
|
||||
|
||||
resAny = any(r)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return resZero, fmt.Errorf("cannot convert packed array to non-any Go type %s", typ.String())
|
||||
}
|
||||
|
||||
a, err := GoAssociativeArray[T](unsafe.Pointer(zval))
|
||||
if err != nil {
|
||||
return resZero, err
|
||||
}
|
||||
|
||||
resAny = any(a)
|
||||
default:
|
||||
return nil
|
||||
return resZero, fmt.Errorf("unsupported zval type %d", t)
|
||||
}
|
||||
|
||||
if resAny == nil {
|
||||
return resZero, nil
|
||||
}
|
||||
|
||||
if castRes, ok := resAny.(T); ok {
|
||||
return castRes, nil
|
||||
}
|
||||
|
||||
return resZero, fmt.Errorf("cannot cast value of type %T to type %T", resAny, res)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: PHPValue converts a Go any to a PHP zval
|
||||
//
|
||||
// nil, bool, int, int64, float64, string, []any, and map[string]any are currently supported.
|
||||
// Any other type will cause a panic.
|
||||
// More types may be supported in the future.
|
||||
func PHPValue(value any) unsafe.Pointer {
|
||||
return unsafe.Pointer(phpValue(value))
|
||||
}
|
||||
@@ -263,6 +365,10 @@ func PHPValue(value any) unsafe.Pointer {
|
||||
func phpValue(value any) *C.zval {
|
||||
var zval C.zval
|
||||
|
||||
if toZvalObj, ok := value.(toZval); ok {
|
||||
return toZvalObj.toZval()
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
C.__zval_null__(&zval)
|
||||
@@ -281,10 +387,8 @@ func phpValue(value any) *C.zval {
|
||||
}
|
||||
str := (*C.zend_string)(PHPString(v, false))
|
||||
C.__zval_string__(&zval, str)
|
||||
case AssociativeArray:
|
||||
return (*C.zval)(PHPAssociativeArray(v))
|
||||
case map[string]any:
|
||||
return (*C.zval)(PHPAssociativeArray(AssociativeArray{Map: v}))
|
||||
return (*C.zval)(PHPAssociativeArray[any](AssociativeArray[any]{Map: v}))
|
||||
case []any:
|
||||
return (*C.zval)(PHPPackedArray(v))
|
||||
default:
|
||||
@@ -308,25 +412,31 @@ func htIsPacked(ht *C.HashTable) bool {
|
||||
}
|
||||
|
||||
// extractZvalValue returns a pointer to the zval value cast to the expected type
|
||||
func extractZvalValue(zval *C.zval, expectedType C.uint8_t) unsafe.Pointer {
|
||||
if zval == nil || C.zval_get_type(zval) != expectedType {
|
||||
return nil
|
||||
func extractZvalValue(zval *C.zval, expectedType C.uint8_t) (unsafe.Pointer, error) {
|
||||
if zval == nil {
|
||||
if expectedType == C.IS_NULL {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("zval type mismatch: expected %d, got nil", expectedType)
|
||||
}
|
||||
|
||||
if zType := C.zval_get_type(zval); zType != expectedType {
|
||||
return nil, fmt.Errorf("zval type mismatch: expected %d, got %d", expectedType, zType)
|
||||
}
|
||||
|
||||
v := unsafe.Pointer(&zval.value[0])
|
||||
|
||||
switch expectedType {
|
||||
case C.IS_LONG:
|
||||
return v
|
||||
case C.IS_DOUBLE:
|
||||
return v
|
||||
case C.IS_LONG, C.IS_DOUBLE:
|
||||
return v, nil
|
||||
case C.IS_STRING:
|
||||
return unsafe.Pointer(*(**C.zend_string)(v))
|
||||
return unsafe.Pointer(*(**C.zend_string)(v)), nil
|
||||
case C.IS_ARRAY:
|
||||
return unsafe.Pointer(*(**C.zend_array)(v))
|
||||
default:
|
||||
return nil
|
||||
return unsafe.Pointer(*(**C.zend_array)(v)), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported zval type %d", expectedType)
|
||||
}
|
||||
|
||||
func zvalPtrDtor(p unsafe.Pointer) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap/exp/zapslog"
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
@@ -53,13 +54,15 @@ func TestGoString(t *testing.T) {
|
||||
|
||||
func TestPHPMap(t *testing.T) {
|
||||
testOnDummyPHPThread(t, func() {
|
||||
originalMap := map[string]any{
|
||||
originalMap := map[string]string{
|
||||
"foo1": "bar1",
|
||||
"foo2": "bar2",
|
||||
}
|
||||
|
||||
phpArray := PHPMap(originalMap)
|
||||
defer zvalPtrDtor(phpArray)
|
||||
defer zvalPtrDtor(phpArray)
|
||||
convertedMap, err := GoMap[string](phpArray)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, originalMap, GoMap(phpArray), "associative array should be equal after conversion")
|
||||
})
|
||||
@@ -67,8 +70,8 @@ func TestPHPMap(t *testing.T) {
|
||||
|
||||
func TestOrderedPHPAssociativeArray(t *testing.T) {
|
||||
testOnDummyPHPThread(t, func() {
|
||||
originalArray := AssociativeArray{
|
||||
Map: map[string]any{
|
||||
originalArray := AssociativeArray[string]{
|
||||
Map: map[string]string{
|
||||
"foo1": "bar1",
|
||||
"foo2": "bar2",
|
||||
},
|
||||
@@ -76,7 +79,9 @@ func TestOrderedPHPAssociativeArray(t *testing.T) {
|
||||
}
|
||||
|
||||
phpArray := PHPAssociativeArray(originalArray)
|
||||
defer zvalPtrDtor(phpArray)
|
||||
defer zvalPtrDtor(phpArray)
|
||||
convertedArray, err := GoAssociativeArray[string](phpArray)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, originalArray, GoAssociativeArray(phpArray), "associative array should be equal after conversion")
|
||||
})
|
||||
@@ -84,10 +89,12 @@ func TestOrderedPHPAssociativeArray(t *testing.T) {
|
||||
|
||||
func TestPHPPackedArray(t *testing.T) {
|
||||
testOnDummyPHPThread(t, func() {
|
||||
originalSlice := []any{"bar1", "bar2"}
|
||||
originalSlice := []string{"bar1", "bar2"}
|
||||
|
||||
phpArray := PHPPackedArray(originalSlice)
|
||||
phpArray := PHPPackedArray(originalSlice)
|
||||
defer zvalPtrDtor(phpArray)
|
||||
convertedSlice, err := GoPackedArray[string](phpArray)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, originalSlice, GoPackedArray(phpArray), "slice should be equal after conversion")
|
||||
})
|
||||
@@ -95,14 +102,16 @@ func TestPHPPackedArray(t *testing.T) {
|
||||
|
||||
func TestPHPPackedArrayToGoMap(t *testing.T) {
|
||||
testOnDummyPHPThread(t, func() {
|
||||
originalSlice := []any{"bar1", "bar2"}
|
||||
expectedMap := map[string]any{
|
||||
originalSlice := []string{"bar1", "bar2"}
|
||||
expectedMap := map[string]string{
|
||||
"0": "bar1",
|
||||
"1": "bar2",
|
||||
}
|
||||
|
||||
phpArray := PHPPackedArray(originalSlice)
|
||||
phpArray := PHPPackedArray(originalSlice)
|
||||
defer zvalPtrDtor(phpArray)
|
||||
convertedMap, err := GoMap[string](phpArray)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expectedMap, GoMap(phpArray), "convert a packed to an associative array")
|
||||
})
|
||||
@@ -110,17 +119,19 @@ func TestPHPPackedArrayToGoMap(t *testing.T) {
|
||||
|
||||
func TestPHPAssociativeArrayToPacked(t *testing.T) {
|
||||
testOnDummyPHPThread(t, func() {
|
||||
originalArray := AssociativeArray{
|
||||
Map: map[string]any{
|
||||
originalArray := AssociativeArray[string]{
|
||||
Map: map[string]string{
|
||||
"foo1": "bar1",
|
||||
"foo2": "bar2",
|
||||
},
|
||||
Order: []string{"foo1", "foo2"},
|
||||
}
|
||||
expectedSlice := []any{"bar1", "bar2"}
|
||||
expectedSlice := []string{"bar1", "bar2"}
|
||||
|
||||
phpArray := PHPAssociativeArray(originalArray)
|
||||
defer zvalPtrDtor(phpArray)
|
||||
convertedSlice, err := GoPackedArray[string](phpArray)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expectedSlice, GoPackedArray(phpArray), "convert an associative array to a slice")
|
||||
})
|
||||
@@ -131,12 +142,12 @@ func TestNestedMixedArray(t *testing.T) {
|
||||
originalArray := map[string]any{
|
||||
"string": "value",
|
||||
"int": int64(123),
|
||||
"float": float64(1.2),
|
||||
"float": 1.2,
|
||||
"true": true,
|
||||
"false": false,
|
||||
"nil": nil,
|
||||
"packedArray": []any{"bar1", "bar2"},
|
||||
"associativeArray": AssociativeArray{
|
||||
"associativeArray": AssociativeArray[any]{
|
||||
Map: map[string]any{"foo1": "bar1", "foo2": "bar2"},
|
||||
Order: []string{"foo2", "foo1"},
|
||||
},
|
||||
@@ -144,6 +155,8 @@ func TestNestedMixedArray(t *testing.T) {
|
||||
|
||||
phpArray := PHPMap(originalArray)
|
||||
defer zvalPtrDtor(phpArray)
|
||||
convertedArray, err := GoMap[any](phpArray)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, originalArray, GoMap(phpArray), "nested mixed array should be equal after conversion")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user