Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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_worktrees` | The [git worktrees](worktrees.md) Flock serves: their branch, URL, folder, setup and database |
Expand All @@ -69,7 +69,7 @@ These tools change things, so clients ask you first:
| `link_site` | Serve a project as a new site |
| `proxy_site` | Proxy `<name>.test` to a local service, e.g. a dashboard or a Docker container, optionally with the command that starts its server |
| `proxy_server` | Start or stop the server of a proxy that has a command |
| `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, serve it as a PHP site or a Node.js app, or restart or stop 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, serve it as a PHP site or a Node.js app, run, stop or restart its Reverb server, or restart or stop it |
| `set_debugging` | Turn Xdebug or dump capture on or off |
| `create_database` | Create a database in PostgreSQL, MySQL or MariaDB |
| `create_worktree` | Create a [git worktree](worktrees.md) of a site on a branch (or one Flock names), at its own URL, and wait while Flock sets it up |
Expand Down
4 changes: 4 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ This page lists the `flock` commands. The other pages show where to find each ac
| `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, 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. This needs Reverb's default paths, so leave `REVERB_SERVER_PATH` unset.

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. It restarts on its own, so the site's FrankenPHP keeps running, and restarting FrankenPHP keeps Reverb's connections. Reverb's output goes to the `reverb-acme` [log](logs.md).

:::app
In **Settings → Sites**, select the site and turn on **Run Reverb** in its **Reverb** section. It shows the `.env` lines with a **Copy** button, Reverb's state and output, and **Restart**.
:::

:::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.
31 changes: 27 additions & 4 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,12 @@ 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. ReverbAvailable
// is set when the project has Laravel Reverb installed, so it can be turned on.
Reverb *SiteReverb `json:"reverb,omitempty"`
ReverbAvailable bool `json:"reverb_available"`
// Type is what the site is: "php" or "node" (see config.Site.Type). A Node.js site has
// NodeServer instead of the PHP settings (PHP, Root, Server, FrankenPHP).
// NodeServer instead of the PHP settings (PHP, Root, Server, FrankenPHP, Reverb).
Type string `json:"type"`
NodeServer *SiteNodeServer `json:"node_server,omitempty"`
// Boost is whether the project uses Laravel Boost and has Flock's guideline (see package boost):
Expand Down Expand Up @@ -244,6 +248,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 @@ -301,8 +321,10 @@ 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 FrankenPHP site's process or a Node.js site's dev server, e.g. so it
// loads changed code; RestartReverb does the same for a site's Reverb server.
RestartSite(name string) error
RestartReverb(name string) error
// StopSite puts a Node.js site's dev server to sleep until the site's next request.
StopSite(name string) error
// StartProxy and StopProxy run a proxy's command and stop its server (see config.Proxy.Command).
Expand Down Expand Up @@ -427,10 +449,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
38 changes: 38 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func (f *fakeBackend) Catalog() *catalog.Manager { return f.catalo
func (f *fakeBackend) FrankenPHP() *frankenphp.Manager { return f.frankenphp }
func (f *fakeBackend) Binaries() *binaries.Catalog { return f.binaries }
func (f *fakeBackend) RestartSite(string) error { return nil }
func (f *fakeBackend) RestartReverb(string) error { return nil }
func (f *fakeBackend) StopSite(string) error { return nil }
func (f *fakeBackend) Shares() *share.Manager { return nil }
func (f *fakeBackend) StartShare(string, []string, bool) error { return nil }
Expand Down Expand Up @@ -457,6 +458,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 @@ -22,6 +22,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 @@ -282,6 +283,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 @@ -340,6 +342,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
if err := cfg.Upsert(site); err != nil {
Expand Down Expand Up @@ -633,6 +641,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 @@ -715,7 +724,8 @@ func (c *Client) RemoveProxy(ctx context.Context, 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
14 changes: 14 additions & 0 deletions internal/api/frankenphpapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ func registerFrankenPHP(mux *http.ServeMux, b Backend) {
}
writeJSON(w, http.StatusOK, b.Status())
})
mux.HandleFunc("POST /v1/sites/{name}/reverb/restart", func(w http.ResponseWriter, r *http.Request) {
if err := b.RestartReverb(r.PathValue("name")); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, b.Status())
})
mux.HandleFunc("POST /v1/sites/{name}/stop", func(w http.ResponseWriter, r *http.Request) {
if err := b.StopSite(r.PathValue("name")); err != nil {
writeError(w, statusFor(err), err)
Expand Down Expand Up @@ -156,3 +163,10 @@ func (c *Client) RestartSite(ctx context.Context, name string) (Status, error) {
err := c.timeout(time.Minute).do(ctx, http.MethodPost, "/v1/sites/"+name+"/restart", &out)
return out, err
}

// RestartReverb restarts a site's Reverb server.
func (c *Client) RestartReverb(ctx context.Context, name string) (Status, error) {
var out Status
err := c.timeout(time.Minute).do(ctx, http.MethodPost, "/v1/sites/"+url.PathEscape(name)+"/reverb/restart", &out)
return out, err
}
130 changes: 130 additions & 0 deletions internal/cli/reverb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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)
if err := s.cfg.Upsert(site); err != nil {
return err
}
if err := s.commit(cmd.Context(), cmd.ErrOrStderr()); err != nil {
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, 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().RestartReverb(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 "))
}
}
Loading
Loading