From 5436df2cf402bb9b9501956e6f2fcaa7b410535f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 02:49:46 +0000 Subject: [PATCH] feat: FrankenPHP Go embedding library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bridge.go | 98 ++++++++++++++++++++++++++++++++ env.go | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++ extract.go | 49 ++++++++++++++++ go.mod | 22 ++++++++ go.sum | 50 ++++++++++++++++ handler.go | 153 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 535 insertions(+) create mode 100644 bridge.go create mode 100644 env.go create mode 100644 extract.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handler.go diff --git a/bridge.go b/bridge.go new file mode 100644 index 0000000..0fc675f --- /dev/null +++ b/bridge.go @@ -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) +} diff --git a/env.go b/env.go new file mode 100644 index 0000000..f59faf5 --- /dev/null +++ b/env.go @@ -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 +} diff --git a/extract.go b/extract.go new file mode 100644 index 0000000..b188307 --- /dev/null +++ b/extract.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2f75626 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d40b422 --- /dev/null +++ b/go.sum @@ -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= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..af93f30 --- /dev/null +++ b/handler.go @@ -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) + } +}