Skip to content
4 changes: 2 additions & 2 deletions docs/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ These tools only read, so most clients run them without asking:
| Tool | What it does |
| --- | --- |
| `status` | Whether Flock runs, the default PHP and Node.js versions, and any problems |
| `site_info` | A site's URLs, folder, HTTPS, PHP and Node.js versions (and where they come from), and its server |
| `site_info` | A site's URLs, folder, HTTPS, PHP and Node.js versions (and where they come from), its server, and its Reverb server with the `.env` lines it needs |
| `php_extensions` | The PHP extensions a site requires or uses, and whether Flock's PHP has each |
| `list_sites` | Every site with its URL, folder and PHP version |
| `list_services` | Databases and other services: installed, running, their port and the `.env` settings to connect |
Expand All @@ -66,7 +66,7 @@ These tools change things, so clients ask you first:
| `service` | Install, start or stop a service |
| `install_version` | Install a PHP or Node.js version |
| `link_site` | Serve a project as a new site |
| `update_site` | Turn HTTPS on or off, isolate, unisolate, pin or unpin a version, add a domain, switch between FrankenPHP (with or without Octane) and PHP-FPM, or restart it |
| `update_site` | Turn HTTPS on or off, isolate, unisolate, pin or unpin a version, add a domain, switch between FrankenPHP (with or without Octane) and PHP-FPM, run or stop its Reverb server, or restart it |
| `set_debugging` | Turn Xdebug or dump capture on or off |
| `create_database` | Create a database in PostgreSQL, MySQL or MariaDB |

Expand Down
6 changes: 5 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,14 @@ This page lists the `flock` commands. The other pages show where to find each ac
| `flock frankenphp` | Show the current site's server and the installed FrankenPHP builds |
| `flock frankenphp on [site] [--octane\|--classic]` | Serve a site with FrankenPHP, optionally in Laravel Octane's worker mode |
| `flock frankenphp off [site]` | Serve a site with PHP-FPM again (php-cgi on Windows) |
| `flock frankenphp restart [site]` | Restart a site's FrankenPHP, e.g. to load changed code into Octane's workers |
| `flock frankenphp restart [site]` | Restart a site's FrankenPHP (and its Reverb server), e.g. to load changed code into Octane's workers |
| `flock frankenphp list` | List FrankenPHP builds, installed and available |
| `flock frankenphp install <php-version>` | Install or update FrankenPHP for a PHP version (`--from <dir\|archive>` for a local package) |
| `flock frankenphp uninstall <php-version>` | Remove FrankenPHP for a PHP version |
| `flock reverb [site]` | Show a site's [Reverb](php.md#laravel-reverb) server and the `.env` lines its app needs |
| `flock reverb on [site]` | Run a Laravel app's Reverb server, reached at `wss://<site>.test/app` |
| `flock reverb off [site]` | Stop a site's Reverb server |
| `flock reverb restart [site]` | Restart a site's Reverb server (and its FrankenPHP), e.g. to load changed code |

## Services

Expand Down
2 changes: 1 addition & 1 deletion docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Flock is a free, open-source local development environment for Laravel on **Wind

Flock is a single `flock` binary that runs as a small background service (the *daemon*):

- An embedded [Caddy](https://caddyserver.com) web server listens on ports 80 and 443 and passes PHP requests to PHP-FPM (macOS and Linux) or a pool of `php-cgi` workers (Windows). A site can also run on [FrankenPHP](php.md#frankenphp), with Laravel Octane's worker mode if you like.
- An embedded [Caddy](https://caddyserver.com) web server listens on ports 80 and 443 and passes PHP requests to PHP-FPM (macOS and Linux) or a pool of `php-cgi` workers (Windows). A site can also run on [FrankenPHP](php.md#frankenphp), with Laravel Octane's worker mode if you like. For a Laravel app, Flock can run its [Reverb](php.md#laravel-reverb) server, reached at the site's own address.
- An embedded DNS server answers for `*.test`, so new sites work immediately without editing your hosts file.
- A local certificate authority issues HTTPS certificates. Its root is restricted to your local TLD, so it can't be used to impersonate real websites.
- Small **shims** for `php`, `composer`, `node`, `npm`, `npx` and the database clients sit on your PATH and run the version that the current project uses.
Expand Down
34 changes: 34 additions & 0 deletions docs/php.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,40 @@ flock frankenphp install 8.4 # install or update the build for a PHP vers
Run them without a site name inside the project folder.
:::

## Laravel Reverb

Flock can run a Laravel app's [Reverb](https://reverb.laravel.com) server for its site. Browsers reach it at the site's own address, `wss://acme.test/app`, through Flock's web server, so there's no port to open and no separate certificate. Flock runs `php artisan reverb:start` in the project with the site's PHP version, on a loopback port of its own. The project needs Laravel Reverb (`php artisan install:broadcasting --reverb`).

Only Reverb's traffic goes to it: WebSocket connections under `/app/` and the signed requests Laravel broadcasts through under `/apps/`. Every other request on those paths stays the app's, so a panel at `/app` keeps working.

The app has to send its broadcasts and its Echo client to the site rather than to Reverb's own port. Flock shows the `.env` lines that do this, but doesn't change the project's `.env` itself:

```env
REVERB_HOST=acme.test
REVERB_PORT=443
REVERB_SCHEME=https
```

On a site served over HTTP only, they are `REVERB_PORT=80` and `REVERB_SCHEME=http`. `install:broadcasting --reverb` writes the `VITE_REVERB_*` variables as references to these, so the Echo client follows them. Rebuild the front end (`npm run build`, or restart `npm run dev`) after changing them.

Restart the Reverb server to load changed code. A restart also restarts the site's FrankenPHP if the site runs on it, and `flock frankenphp restart` restarts Reverb too. Reverb's output goes to the `reverb-acme` [log](logs.md).

:::app
Reverb isn't in the app yet. Use the CLI commands below, or ask an [AI agent](ai.md) to turn it on: Flock's `update_site` tool runs and stops it.
:::

:::cli
```bash
flock reverb on acme # run the site's Reverb server; prints the .env lines
flock reverb acme # its state and the .env lines
flock reverb restart acme # load changed code
flock reverb off acme # stop it
flock logs reverb-acme # its output
```

Run them without a site name inside the project folder. `flock sites` marks a site with Reverb on as `+ Reverb`.
:::

## Composer

`composer` is included and runs with the PHP version of the current project, so `composer install` always matches the version the browser uses. If the project's PHP version isn't installed yet, `php` and `composer` offer to install it the first time you run them.
25 changes: 22 additions & 3 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ type SiteStatus struct {
// FrankenPHP is the state of the site's FrankenPHP process, when Server is "frankenphp". Until
// it runs, the site is served through FastCGI.
FrankenPHP *SiteFrankenPHP `json:"frankenphp,omitempty"`
// Reverb is the state of the site's Laravel Reverb server, when it's turned on.
Reverb *SiteReverb `json:"reverb,omitempty"`
// Boost is whether the project uses Laravel Boost and has Flock's guideline (see package boost):
// "" (no Boost), "missing", "current", "outdated" or "custom".
Boost string `json:"boost,omitempty"`
Expand All @@ -170,6 +172,22 @@ type SiteFrankenPHP struct {
Log string `json:"log"` // name for /v1/logs/services/{name}
}

// Reverb states of a site.
const (
ReverbRunning = "running"
ReverbFailed = "failed" // see Problem
ReverbUnavailable = "unavailable" // the project has no Laravel Reverb; see Problem
)

// SiteReverb is a site's Laravel Reverb server.
type SiteReverb struct {
State string `json:"state"`
Port int `json:"port"` // loopback port the web server proxies to
Env []string `json:"env,omitempty"` // the app's .env lines for reaching it through the web server
Problem string `json:"problem,omitempty"`
Log string `json:"log"` // name for /v1/logs/services/{name}
}

// SiteDomain is one of a site's extra domains.
type SiteDomain struct {
Entry string `json:"entry"` // as stored and passed to the API: "tenant1" or "my-app-2.test"
Expand Down Expand Up @@ -227,7 +245,7 @@ type Backend interface {
FrankenPHP() *frankenphp.Manager
// Binaries is the package manifest of the configured channel (nil when there is none).
Binaries() *binaries.Catalog
// RestartSite restarts a FrankenPHP site's process, e.g. so Octane's workers load changed code.
// RestartSite restarts a site's FrankenPHP and Reverb processes, e.g. so they load changed code.
RestartSite(name string) error
// Shares runs the sites' tunnels (nil when unavailable); StartShare starts one after checking
// that the site can be shared, with the app's debug mode on for visitors when debug is set.
Expand Down Expand Up @@ -331,10 +349,11 @@ func (c *Client) Status(ctx context.Context) (Status, error) {
return status, err
}

// Reload asks the daemon to apply config.json and returns the resulting status.
// Reload asks the daemon to apply config.json and returns the resulting status. It may start a
// site's FrankenPHP or Reverb and wait for it (Octane up to 20 s), so it gets UpdateSite's minute.
func (c *Client) Reload(ctx context.Context) (Status, error) {
var status Status
err := c.do(ctx, http.MethodPost, "/v1/reload", &status)
err := c.timeout(time.Minute).do(ctx, http.MethodPost, "/v1/reload", &status)

return status, err
}
Expand Down
37 changes: 37 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,43 @@ func TestSitesSwitchToFrankenPHPAndBack(t *testing.T) {
}
}

func TestSitesTurnReverbOnAndOff(t *testing.T) {
root := t.TempDir()
b := &fakeBackend{root: root}
srv := serve(t, b, "")
app, plain := t.TempDir(), t.TempDir()
for _, file := range []string{filepath.Join(app, "artisan"), filepath.Join(app, "vendor", "laravel", "reverb", "composer.json")} {
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
}
cfg := b.config(t)
cfg.Upsert(config.Site{Name: "app", Path: app})
cfg.Upsert(config.Site{Name: "plain", Path: plain})
if err := cfg.Save(root); err != nil {
t.Fatal(err)
}

if resp, body := call(t, "PATCH", srv.URL+"/v1/sites/app", "", `{"reverb":true}`); resp.StatusCode != http.StatusOK {
t.Fatalf("on: %d %s", resp.StatusCode, body)
}
if site, _ := b.config(t).FindSite("app"); !site.Reverb || site.ReverbPort != config.FirstSitePort {
t.Fatalf("site: %+v", site)
}
if resp, body := call(t, "PATCH", srv.URL+"/v1/sites/app", "", `{"reverb":false}`); resp.StatusCode != http.StatusOK {
t.Fatalf("off: %d %s", resp.StatusCode, body)
}
if site, _ := b.config(t).FindSite("app"); site.Reverb || site.ReverbPort != config.FirstSitePort {
t.Fatalf("site: %+v", site)
}
if resp, body := call(t, "PATCH", srv.URL+"/v1/sites/plain", "", `{"reverb":true}`); resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "install:broadcasting --reverb") {
t.Fatalf("without Reverb: %d %s", resp.StatusCode, body)
}
}

// jsonString quotes s for a JSON body (Windows paths have backslashes).
func jsonString(s string) string {
b, _ := json.Marshal(s)
Expand Down
12 changes: 11 additions & 1 deletion internal/api/configapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"flock/internal/php"
"flock/internal/ports"
"flock/internal/project"
"flock/internal/reverb"
)

// Service is one background service as shown in the Services settings.
Expand Down Expand Up @@ -243,6 +244,7 @@ func registerConfig(mux *http.ServeMux, b Backend) {
// Server is "fastcgi" or "frankenphp"; Worker "" (classic) or "octane" (FrankenPHP's worker mode).
Server *string `json:"server"`
Worker *string `json:"worker"`
Reverb *bool `json:"reverb"`
// Root is the document root on this machine, relative to the project ("" lets the
// project's .flockrc or detection decide again).
Root *string `json:"root"`
Expand Down Expand Up @@ -296,6 +298,12 @@ func registerConfig(mux *http.ServeMux, b Backend) {
return err
}
}
if body.Reverb != nil {
if *body.Reverb && !reverb.Installed(site.Path) {
return invalid("Laravel Reverb isn't installed in %s — run `php artisan install:broadcasting --reverb`", site.Path)
}
cfg.SetReverb(&site, *body.Reverb)
}
// A parked site becomes linked so its settings can be stored.
site.Parked = false
cfg.Upsert(site)
Expand Down Expand Up @@ -520,6 +528,7 @@ type SiteChange struct {
Node *string `json:"node,omitempty"`
Server *string `json:"server,omitempty"`
Worker *string `json:"worker,omitempty"`
Reverb *bool `json:"reverb,omitempty"`
Root *string `json:"root,omitempty"`
}

Expand Down Expand Up @@ -555,7 +564,8 @@ func (c *Client) LinkSite(ctx context.Context, dir, name string) (Status, error)
return out, err
}

// UpdateSite changes a site's settings. Switching servers can take a while (FrankenPHP starts).
// UpdateSite changes a site's settings. Switching servers or turning on Reverb can take a while
// (the process starts).
func (c *Client) UpdateSite(ctx context.Context, name string, change SiteChange) (Status, error) {
var out Status
err := c.timeout(time.Minute).send(ctx, http.MethodPatch, "/v1/sites/"+url.PathEscape(name), change, &out)
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ first time a site needs it. php.ini is the regular PHP's (see ` + "`flock php li

restart := &cobra.Command{
Use: "restart [site]",
Short: "Restart a site's FrankenPHP (Octane's workers load changed code)",
Short: "Restart a site's FrankenPHP and Reverb (Octane's workers load changed code)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := load()
Expand Down
128 changes: 128 additions & 0 deletions internal/cli/reverb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package cli

import (
"errors"
"strings"

"github.com/spf13/cobra"

"flock/internal/api"
"flock/internal/reverb"
)

func newReverbCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "reverb",
Short: "Run a Laravel app's Reverb server, reachable at wss://<site>.test/app",
Long: `Flock runs ` + "`php artisan reverb:start`" + ` for the site with its PHP version, on a loopback port, and its
web server sends the site's WebSocket connections (/app/...) and broadcasts (/apps/...) there.
Browsers connect to the site's own address, over HTTPS when the site is secured; the app's .env
needs the REVERB_HOST, REVERB_PORT and REVERB_SCHEME lines ` + "`flock reverb`" + ` shows.

flock reverb [site] show the site's Reverb server and its .env lines
flock reverb on [site] run it (the project needs ` + "`php artisan install:broadcasting --reverb`" + `)
flock reverb off [site] stop it
flock reverb restart [site] restart it, e.g. to load changed code`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := load()
if err != nil {
return err
}
site, err := siteArg(s, args)
if err != nil {
return err
}
return showReverb(cmd, s, site.Name)
},
}

toggle := func(on bool) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
s, err := load()
if err != nil {
return err
}
site, err := siteArg(s, args)
if err != nil {
return err
}
if on && !reverb.Installed(site.Path) {
return errors.New("Laravel Reverb isn't installed in " + site.Path + " — run `php artisan install:broadcasting --reverb` first")
}
s.cfg.SetReverb(&site, on)
s.cfg.Upsert(site)
if err := s.commit(cmd.Context(), cmd.ErrOrStderr()); err != nil {
Comment thread
onairmarc marked this conversation as resolved.
Resolved
return err
}
if !on {
cmd.Printf("stopped %s's Reverb server\n", site.Name)
return nil
}
// commit has already said that a stopped daemon applies the change when it starts.
if err := showReverb(cmd, s, site.Name); !errors.Is(err, api.ErrDaemonNotRunning) {
return err
}
return nil
}
}
on := &cobra.Command{Use: "on [site]", Short: "Run a site's Reverb server", Args: cobra.MaximumNArgs(1), RunE: toggle(true)}
off := &cobra.Command{Use: "off [site]", Short: "Stop a site's Reverb server", Args: cobra.MaximumNArgs(1), RunE: toggle(false)}

restart := &cobra.Command{
Use: "restart [site]",
Short: "Restart a site's Reverb server (and its FrankenPHP), e.g. to load changed code",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := load()
if err != nil {
return err
}
site, err := siteArg(s, args)
if err != nil {
return err
}
if !site.Reverb {
return errors.New("Reverb is off for " + site.Name + " — `flock reverb on` runs it")
}
st, err := s.client().RestartSite(cmd.Context(), site.Name)
if err != nil {
return err
}
printReverb(cmd, st, site.Name)
return nil
},
}

cmd.AddCommand(on, off, restart)
return cmd
}

// showReverb prints the site's Reverb server as the daemon reports it.
func showReverb(cmd *cobra.Command, s *state, name string) error {
st, err := s.client().Status(cmd.Context())
if err != nil {
return err
}
printReverb(cmd, st, name)
return nil
}

func printReverb(cmd *cobra.Command, st api.Status, name string) {
for _, site := range st.Sites {
if site.Name != name {
continue
}
r := site.Reverb
switch {
case r == nil:
cmd.Printf("Reverb is off for %s — `flock reverb on` runs it\n", name)
return
case r.State == api.ReverbRunning:
cmd.Printf("%s's Reverb server is running, log: `flock logs %s`\n", name, r.Log)
default:
cmd.Printf("%s's Reverb server isn't running: %s\n", name, r.Problem)
}
cmd.Printf("in the app's .env:\n %s\n", strings.Join(r.Env, "\n "))
}
}
30 changes: 30 additions & 0 deletions internal/cli/reverb_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cli

import (
"errors"
"io"
"testing"

"flock/internal/api"
"flock/internal/config"
)

func TestReverbStatusSaysWhenTheDaemonIsNotRunning(t *testing.T) {
root := t.TempDir()
t.Setenv("FLOCK_HOME", root)
cfg := config.Default()
cfg.Upsert(config.Site{Name: "app", Path: t.TempDir(), Reverb: true, ReverbPort: 21000})
if err := cfg.Save(root); err != nil {
t.Fatal(err)
}

cmd := newReverbCommand()
cmd.SetArgs([]string{"app"})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

// It used to exit successfully without printing anything.
if err := cmd.Execute(); !errors.Is(err, api.ErrDaemonNotRunning) {
t.Fatalf("err = %v, want %v", err, api.ErrDaemonNotRunning)
}
}
Loading
Loading