feat: FrankenPHP Go embedding library
Extracted from host-uk/core/cmd/core-app/ into a reusable package. Provides: - Handler: http.Handler serving PHP via in-process FrankenPHP - Extract: embed.FS to temp dir extraction for PHP runtime - PrepareEnvironment: .env, SQLite, persistent storage setup - Bridge: localhost HTTP API for PHP → Go communication Requires CGo + PHP headers. Build with: -tags nowatcher Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
5436df2cf4
6 changed files with 535 additions and 0 deletions
98
bridge.go
Normal file
98
bridge.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// BridgeHandler is the interface that the host application implements to
|
||||
// respond to PHP-initiated requests via the native bridge.
|
||||
type BridgeHandler interface {
|
||||
// HandleBridgeCall processes a named bridge call with JSON args.
|
||||
// Returns a JSON-serializable response.
|
||||
HandleBridgeCall(method string, args json.RawMessage) (any, error)
|
||||
}
|
||||
|
||||
// Bridge provides a localhost HTTP API that PHP code can call
|
||||
// to access native desktop capabilities (file dialogs, notifications, etc.).
|
||||
//
|
||||
// Livewire renders server-side in PHP, so it can't call Wails bindings
|
||||
// (window.go.*) directly. Instead, PHP makes HTTP requests to this bridge.
|
||||
// The bridge port is injected into Laravel's .env as NATIVE_BRIDGE_URL.
|
||||
type Bridge struct {
|
||||
server *http.Server
|
||||
port int
|
||||
handler BridgeHandler
|
||||
}
|
||||
|
||||
// NewBridge creates and starts the bridge on a random available port.
|
||||
// The handler processes incoming PHP requests via HandleBridgeCall.
|
||||
func NewBridge(handler BridgeHandler) (*Bridge, error) {
|
||||
mux := http.NewServeMux()
|
||||
bridge := &Bridge{handler: handler}
|
||||
|
||||
mux.HandleFunc("GET /bridge/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
bridgeJSON(w, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /bridge/call", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := handler.HandleBridgeCall(req.Method, req.Args)
|
||||
if err != nil {
|
||||
bridgeJSON(w, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
bridgeJSON(w, map[string]any{"result": result})
|
||||
})
|
||||
|
||||
// Listen on a random available port (localhost only)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
|
||||
bridge.port = listener.Addr().(*net.TCPAddr).Port
|
||||
bridge.server = &http.Server{Handler: mux}
|
||||
|
||||
go func() {
|
||||
if err := bridge.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("go-php: bridge error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("go-php: bridge listening on http://127.0.0.1:%d", bridge.port)
|
||||
return bridge, nil
|
||||
}
|
||||
|
||||
// Port returns the port the bridge is listening on.
|
||||
func (b *Bridge) Port() int {
|
||||
return b.port
|
||||
}
|
||||
|
||||
// URL returns the full base URL of the bridge.
|
||||
func (b *Bridge) URL() string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", b.port)
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the bridge server.
|
||||
func (b *Bridge) Shutdown(ctx context.Context) error {
|
||||
return b.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func bridgeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
163
env.go
Normal file
163
env.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Environment holds the resolved paths for the running application.
|
||||
type Environment struct {
|
||||
// DataDir is the persistent data directory (survives app updates).
|
||||
DataDir string
|
||||
// LaravelRoot is the extracted Laravel app in the temp directory.
|
||||
LaravelRoot string
|
||||
// DatabasePath is the full path to the SQLite database file.
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
// PrepareEnvironment creates data directories, generates .env, and symlinks
|
||||
// storage so Laravel can write to persistent locations.
|
||||
// The appName is used for the data directory name (e.g. "bugseti").
|
||||
func PrepareEnvironment(laravelRoot, appName string) (*Environment, error) {
|
||||
dataDir, err := resolveDataDir(appName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve data dir: %w", err)
|
||||
}
|
||||
|
||||
env := &Environment{
|
||||
DataDir: dataDir,
|
||||
LaravelRoot: laravelRoot,
|
||||
DatabasePath: filepath.Join(dataDir, appName+".sqlite"),
|
||||
}
|
||||
|
||||
// Create persistent directories
|
||||
dirs := []string{
|
||||
dataDir,
|
||||
filepath.Join(dataDir, "storage", "app"),
|
||||
filepath.Join(dataDir, "storage", "framework", "cache", "data"),
|
||||
filepath.Join(dataDir, "storage", "framework", "sessions"),
|
||||
filepath.Join(dataDir, "storage", "framework", "views"),
|
||||
filepath.Join(dataDir, "storage", "logs"),
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create dir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create empty SQLite database if it doesn't exist
|
||||
if _, err := os.Stat(env.DatabasePath); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(env.DatabasePath, nil, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("create database: %w", err)
|
||||
}
|
||||
log.Printf("go-php: created new database: %s", env.DatabasePath)
|
||||
}
|
||||
|
||||
// Replace the extracted storage/ with a symlink to the persistent one
|
||||
extractedStorage := filepath.Join(laravelRoot, "storage")
|
||||
os.RemoveAll(extractedStorage)
|
||||
persistentStorage := filepath.Join(dataDir, "storage")
|
||||
if err := os.Symlink(persistentStorage, extractedStorage); err != nil {
|
||||
return nil, fmt.Errorf("symlink storage: %w", err)
|
||||
}
|
||||
|
||||
// Generate .env file with resolved paths
|
||||
if err := writeEnvFile(laravelRoot, appName, env); err != nil {
|
||||
return nil, fmt.Errorf("write .env: %w", err)
|
||||
}
|
||||
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// AppendEnv appends a key=value pair to the Laravel .env file.
|
||||
func AppendEnv(laravelRoot, key, value string) error {
|
||||
envFile := filepath.Join(laravelRoot, ".env")
|
||||
f, err := os.OpenFile(envFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprintf(f, "%s=\"%s\"\n", key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveDataDir(appName string) (string, error) {
|
||||
var base string
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base = filepath.Join(home, "Library", "Application Support", appName)
|
||||
case "linux":
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
base = filepath.Join(xdg, appName)
|
||||
} else {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base = filepath.Join(home, ".local", "share", appName)
|
||||
}
|
||||
default:
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base = filepath.Join(home, "."+appName)
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func writeEnvFile(laravelRoot, appName string, env *Environment) error {
|
||||
appKey, err := loadOrGenerateAppKey(env.DataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("app key: %w", err)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(`APP_NAME="%s"
|
||||
APP_ENV=production
|
||||
APP_KEY=%s
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE="%s"
|
||||
|
||||
CACHE_STORE=file
|
||||
SESSION_DRIVER=file
|
||||
LOG_CHANNEL=single
|
||||
LOG_LEVEL=warning
|
||||
|
||||
`, appName, appKey, env.DatabasePath)
|
||||
|
||||
return os.WriteFile(filepath.Join(laravelRoot, ".env"), []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func loadOrGenerateAppKey(dataDir string) (string, error) {
|
||||
keyFile := filepath.Join(dataDir, ".app-key")
|
||||
|
||||
data, err := os.ReadFile(keyFile)
|
||||
if err == nil && len(data) > 0 {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return "", fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
appKey := "base64:" + base64.StdEncoding.EncodeToString(key)
|
||||
|
||||
if err := os.WriteFile(keyFile, []byte(appKey), 0o600); err != nil {
|
||||
return "", fmt.Errorf("save key: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("go-php: generated new APP_KEY (saved to %s)", keyFile)
|
||||
return appKey, nil
|
||||
}
|
||||
49
extract.go
Normal file
49
extract.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Extract copies an embedded Laravel app (from embed.FS) to a temporary directory.
|
||||
// FrankenPHP needs real filesystem paths — it cannot serve from embed.FS.
|
||||
// The prefix is the embed directory name (e.g. "laravel").
|
||||
// Returns the path to the extracted Laravel root. Caller must os.RemoveAll on cleanup.
|
||||
func Extract(fsys fs.FS, prefix string) (string, error) {
|
||||
tmpDir, err := os.MkdirTemp("", "go-php-laravel-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
|
||||
err = fs.WalkDir(fsys, prefix, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(prefix, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(tmpDir, relPath)
|
||||
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(targetPath, 0o755)
|
||||
}
|
||||
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded %s: %w", path, err)
|
||||
}
|
||||
|
||||
return os.WriteFile(targetPath, data, 0o644)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", fmt.Errorf("extract Laravel: %w", err)
|
||||
}
|
||||
|
||||
return tmpDir, nil
|
||||
}
|
||||
22
go.mod
Normal file
22
go.mod
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
module forge.lthn.ai/core/go-php
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/dunglas/frankenphp v1.5.0
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dolthub/maphash v0.1.0 // indirect
|
||||
github.com/gammazero/deque v1.0.0 // indirect
|
||||
github.com/maypok86/otter v1.2.4 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_golang v1.21.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
)
|
||||
50
go.sum
Normal file
50
go.sum
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||
github.com/dunglas/frankenphp v1.5.0 h1:mrkJNe2gxlqYijGSpYIVbbRYxjYw2bmgAeDFqwREEk4=
|
||||
github.com/dunglas/frankenphp v1.5.0/go.mod h1:tU9EirkVR0EuIr69IT1XBjSE6YfQY88tZlgkAvLPdOw=
|
||||
github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34=
|
||||
github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
|
||||
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
|
||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
153
handler.go
Normal file
153
handler.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// Package php provides FrankenPHP embedding for Go applications.
|
||||
// Serves a Laravel application via the FrankenPHP runtime, with support
|
||||
// for Octane worker mode (in-memory, sub-ms responses) and standard mode
|
||||
// fallback. Designed for use with Wails v3's AssetOptions.Handler.
|
||||
package php
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dunglas/frankenphp"
|
||||
)
|
||||
|
||||
// Handler implements http.Handler by delegating to FrankenPHP.
|
||||
// It resolves URLs to files (Caddy try_files pattern) before passing
|
||||
// requests to the PHP runtime.
|
||||
type Handler struct {
|
||||
docRoot string
|
||||
laravelRoot string
|
||||
}
|
||||
|
||||
// HandlerConfig configures the FrankenPHP handler.
|
||||
type HandlerConfig struct {
|
||||
// NumThreads is the number of PHP threads (default: 4).
|
||||
NumThreads int
|
||||
// NumWorkers is the number of Octane workers (default: 2).
|
||||
NumWorkers int
|
||||
// PHPIni provides php.ini overrides.
|
||||
PHPIni map[string]string
|
||||
}
|
||||
|
||||
// NewHandler extracts the Laravel app from the given filesystem, prepares the
|
||||
// environment, initialises FrankenPHP with worker mode, and returns the handler.
|
||||
// The cleanup function must be called on shutdown to release resources and remove
|
||||
// the extracted files.
|
||||
func NewHandler(laravelRoot string, cfg HandlerConfig) (*Handler, func(), error) {
|
||||
if cfg.NumThreads == 0 {
|
||||
cfg.NumThreads = 4
|
||||
}
|
||||
if cfg.NumWorkers == 0 {
|
||||
cfg.NumWorkers = 2
|
||||
}
|
||||
if cfg.PHPIni == nil {
|
||||
cfg.PHPIni = map[string]string{
|
||||
"display_errors": "Off",
|
||||
"opcache.enable": "1",
|
||||
}
|
||||
}
|
||||
|
||||
docRoot := filepath.Join(laravelRoot, "public")
|
||||
|
||||
log.Printf("go-php: Laravel root: %s", laravelRoot)
|
||||
log.Printf("go-php: Document root: %s", docRoot)
|
||||
|
||||
// Try Octane worker mode first, fall back to standard mode.
|
||||
// Worker mode keeps Laravel booted in memory — sub-ms response times.
|
||||
workerScript := filepath.Join(laravelRoot, "vendor", "laravel", "octane", "bin", "frankenphp-worker.php")
|
||||
workerEnv := map[string]string{
|
||||
"APP_BASE_PATH": laravelRoot,
|
||||
"FRANKENPHP_WORKER": "1",
|
||||
}
|
||||
|
||||
workerMode := false
|
||||
if _, err := os.Stat(workerScript); err == nil {
|
||||
if err := frankenphp.Init(
|
||||
frankenphp.WithNumThreads(cfg.NumThreads),
|
||||
frankenphp.WithWorkers("laravel", workerScript, cfg.NumWorkers, workerEnv, nil),
|
||||
frankenphp.WithPhpIni(cfg.PHPIni),
|
||||
); err != nil {
|
||||
log.Printf("go-php: worker mode init failed (%v), falling back to standard mode", err)
|
||||
} else {
|
||||
workerMode = true
|
||||
}
|
||||
}
|
||||
|
||||
if !workerMode {
|
||||
if err := frankenphp.Init(
|
||||
frankenphp.WithNumThreads(cfg.NumThreads),
|
||||
frankenphp.WithPhpIni(cfg.PHPIni),
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("init FrankenPHP: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if workerMode {
|
||||
log.Printf("go-php: FrankenPHP initialised (Octane worker mode, %d workers)", cfg.NumWorkers)
|
||||
} else {
|
||||
log.Printf("go-php: FrankenPHP initialised (standard mode, %d threads)", cfg.NumThreads)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
frankenphp.Shutdown()
|
||||
}
|
||||
|
||||
handler := &Handler{
|
||||
docRoot: docRoot,
|
||||
laravelRoot: laravelRoot,
|
||||
}
|
||||
|
||||
return handler, cleanup, nil
|
||||
}
|
||||
|
||||
// LaravelRoot returns the path to the extracted Laravel application.
|
||||
func (h *Handler) LaravelRoot() string {
|
||||
return h.laravelRoot
|
||||
}
|
||||
|
||||
// DocRoot returns the path to the document root (public/).
|
||||
func (h *Handler) DocRoot() string {
|
||||
return h.docRoot
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
urlPath := r.URL.Path
|
||||
filePath := filepath.Join(h.docRoot, filepath.Clean(urlPath))
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err == nil && info.IsDir() {
|
||||
// Directory → try index.php inside it
|
||||
urlPath = strings.TrimRight(urlPath, "/") + "/index.php"
|
||||
} else if err != nil && !strings.HasSuffix(urlPath, ".php") {
|
||||
// File not found and not a .php request → front controller
|
||||
urlPath = "/index.php"
|
||||
}
|
||||
|
||||
// Serve static assets directly (CSS, JS, images)
|
||||
if !strings.HasSuffix(urlPath, ".php") {
|
||||
staticPath := filepath.Join(h.docRoot, filepath.Clean(urlPath))
|
||||
if info, err := os.Stat(staticPath); err == nil && !info.IsDir() {
|
||||
http.ServeFile(w, r, staticPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Route to FrankenPHP
|
||||
r.URL.Path = urlPath
|
||||
|
||||
req, err := frankenphp.NewRequestWithContext(r,
|
||||
frankenphp.WithRequestDocumentRoot(h.docRoot, false),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("FrankenPHP request error: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := frankenphp.ServeHTTP(w, req); err != nil {
|
||||
http.Error(w, fmt.Sprintf("FrankenPHP serve error: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
Reference in a new issue