package php import ( "context" "io" "os" "sync" "time" "github.com/host-uk/core/pkg/cli" ) // Options configures the development server. type Options struct { // Dir is the Laravel project directory. Dir string // Services specifies which services to start. // If empty, services are auto-detected. Services []DetectedService // NoVite disables the Vite dev server. NoVite bool // NoHorizon disables Laravel Horizon. NoHorizon bool // NoReverb disables Laravel Reverb. NoReverb bool // NoRedis disables the Redis server. NoRedis bool // HTTPS enables HTTPS with mkcert certificates. HTTPS bool // Domain is the domain for SSL certificates. // Defaults to APP_URL from .env or "localhost". Domain string // Ports for each service FrankenPHPPort int HTTPSPort int VitePort int ReverbPort int RedisPort int } // DevServer manages all development services. type DevServer struct { opts Options services []Service ctx context.Context cancel context.CancelFunc mu sync.RWMutex running bool } // NewDevServer creates a new development server manager. func NewDevServer(opts Options) *DevServer { return &DevServer{ opts: opts, services: make([]Service, 0), } } // Start starts all detected/configured services. func (d *DevServer) Start(ctx context.Context, opts Options) error { d.mu.Lock() defer d.mu.Unlock() if d.running { return cli.Err("dev server is already running") } // Merge options if opts.Dir != "" { d.opts.Dir = opts.Dir } if d.opts.Dir == "" { cwd, err := os.Getwd() if err != nil { return cli.WrapVerb(err, "get", "working directory") } d.opts.Dir = cwd } // Verify this is a Laravel project if !IsLaravelProject(d.opts.Dir) { return cli.Err("not a Laravel project: %s", d.opts.Dir) } // Create cancellable context d.ctx, d.cancel = context.WithCancel(ctx) // Detect or use provided services services := opts.Services if len(services) == 0 { services = DetectServices(d.opts.Dir) } // Filter out disabled services services = d.filterServices(services, opts) // Setup SSL if HTTPS is enabled var certFile, keyFile string if opts.HTTPS { domain := opts.Domain if domain == "" { // Try to get domain from APP_URL appURL := GetLaravelAppURL(d.opts.Dir) if appURL != "" { domain = ExtractDomainFromURL(appURL) } } if domain == "" { domain = "localhost" } var err error certFile, keyFile, err = SetupSSLIfNeeded(domain, SSLOptions{}) if err != nil { return cli.WrapVerb(err, "setup", "SSL") } } // Create services d.services = make([]Service, 0) for _, svc := range services { var service Service switch svc { case ServiceFrankenPHP: port := opts.FrankenPHPPort if port == 0 { port = 8000 } httpsPort := opts.HTTPSPort if httpsPort == 0 { httpsPort = 443 } service = NewFrankenPHPService(d.opts.Dir, FrankenPHPOptions{ Port: port, HTTPSPort: httpsPort, HTTPS: opts.HTTPS, CertFile: certFile, KeyFile: keyFile, }) case ServiceVite: port := opts.VitePort if port == 0 { port = 5173 } service = NewViteService(d.opts.Dir, ViteOptions{ Port: port, }) case ServiceHorizon: service = NewHorizonService(d.opts.Dir) case ServiceReverb: port := opts.ReverbPort if port == 0 { port = 8080 } service = NewReverbService(d.opts.Dir, ReverbOptions{ Port: port, }) case ServiceRedis: port := opts.RedisPort if port == 0 { port = 6379 } service = NewRedisService(d.opts.Dir, RedisOptions{ Port: port, }) } if service != nil { d.services = append(d.services, service) } } // Start all services var startErrors []error for _, svc := range d.services { if err := svc.Start(d.ctx); err != nil { startErrors = append(startErrors, cli.Err("%s: %v", svc.Name(), err)) } } if len(startErrors) > 0 { // Stop any services that did start for _, svc := range d.services { _ = svc.Stop() } return cli.Err("failed to start services: %v", startErrors) } d.running = true return nil } // filterServices removes disabled services from the list. func (d *DevServer) filterServices(services []DetectedService, opts Options) []DetectedService { filtered := make([]DetectedService, 0) for _, svc := range services { switch svc { case ServiceVite: if !opts.NoVite { filtered = append(filtered, svc) } case ServiceHorizon: if !opts.NoHorizon { filtered = append(filtered, svc) } case ServiceReverb: if !opts.NoReverb { filtered = append(filtered, svc) } case ServiceRedis: if !opts.NoRedis { filtered = append(filtered, svc) } default: filtered = append(filtered, svc) } } return filtered } // Stop stops all services gracefully. func (d *DevServer) Stop() error { d.mu.Lock() defer d.mu.Unlock() if !d.running { return nil } // Cancel context first if d.cancel != nil { d.cancel() } // Stop all services in reverse order var stopErrors []error for i := len(d.services) - 1; i >= 0; i-- { svc := d.services[i] if err := svc.Stop(); err != nil { stopErrors = append(stopErrors, cli.Err("%s: %v", svc.Name(), err)) } } d.running = false if len(stopErrors) > 0 { return cli.Err("errors stopping services: %v", stopErrors) } return nil } // Logs returns a reader for the specified service's logs. // If service is empty, returns unified logs from all services. func (d *DevServer) Logs(service string, follow bool) (io.ReadCloser, error) { d.mu.RLock() defer d.mu.RUnlock() if service == "" { // Return unified logs return d.unifiedLogs(follow) } // Find specific service for _, svc := range d.services { if svc.Name() == service { return svc.Logs(follow) } } return nil, cli.Err("service not found: %s", service) } // unifiedLogs creates a reader that combines logs from all services. func (d *DevServer) unifiedLogs(follow bool) (io.ReadCloser, error) { readers := make([]io.ReadCloser, 0) for _, svc := range d.services { reader, err := svc.Logs(follow) if err != nil { // Close any readers we already opened for _, r := range readers { _ = r.Close() } return nil, cli.Err("failed to get logs for %s: %v", svc.Name(), err) } readers = append(readers, reader) } return newMultiServiceReader(d.services, readers, follow), nil } // Status returns the status of all services. func (d *DevServer) Status() []ServiceStatus { d.mu.RLock() defer d.mu.RUnlock() statuses := make([]ServiceStatus, 0, len(d.services)) for _, svc := range d.services { statuses = append(statuses, svc.Status()) } return statuses } // IsRunning returns true if the dev server is running. func (d *DevServer) IsRunning() bool { d.mu.RLock() defer d.mu.RUnlock() return d.running } // Services returns the list of managed services. func (d *DevServer) Services() []Service { d.mu.RLock() defer d.mu.RUnlock() return d.services } // multiServiceReader combines multiple service log readers. type multiServiceReader struct { services []Service readers []io.ReadCloser follow bool closed bool mu sync.RWMutex } func newMultiServiceReader(services []Service, readers []io.ReadCloser, follow bool) *multiServiceReader { return &multiServiceReader{ services: services, readers: readers, follow: follow, } } func (m *multiServiceReader) Read(p []byte) (n int, err error) { m.mu.RLock() if m.closed { m.mu.RUnlock() return 0, io.EOF } m.mu.RUnlock() // Round-robin read from all readers for i, reader := range m.readers { buf := make([]byte, len(p)) n, err := reader.Read(buf) if n > 0 { // Prefix with service name prefix := cli.Sprintf("[%s] ", m.services[i].Name()) copy(p, prefix) copy(p[len(prefix):], buf[:n]) return n + len(prefix), nil } if err != nil && err != io.EOF { return 0, err } } if m.follow { time.Sleep(100 * time.Millisecond) return 0, nil } return 0, io.EOF } func (m *multiServiceReader) Close() error { m.mu.Lock() m.closed = true m.mu.Unlock() var closeErr error for _, reader := range m.readers { if err := reader.Close(); err != nil && closeErr == nil { closeErr = err } } return closeErr }