feat(surf): print the serve URL and admin status on startup

- Add surf.Router.AdminPrefix (additive): the admin mount path and true
  when BuildRouter mounted the cabana admin, false otherwise
- serve binds with net.Listen before printing, so a busy port fails
  without a misleading URL, then prints the listen address with a
  browser URL and an admin status line: the admin URL, a warning naming
  summer admin:create when backend_users is empty or missing, or
  admin: off with the steps to add an admin controller
- The administrator count is bounded by a 2 s timeout and never fails boot
- route:list ends with the same admin status line
- surf and cabana READMEs and the console and routing docs describe the output
This commit is contained in:
Jakub Zych
2026-10-08 16:36:21 +02:00
parent 4b5ed7707e
commit da21670870
10 changed files with 294 additions and 10 deletions

View File

@@ -178,7 +178,7 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
func (p *Plugin) AdminFS() fs.FS { return adminFS }
```
`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`.
`config_list.yaml` and `config_form.yaml` reference the model files with WinterCMS paths such as `~/plugins/acme/blog/models/post/columns.yaml`. At boot, `surf.BuildRouter` calls `cabana.Activate` with the activated plugins and mounts the returned `cabana.Routes`; when no plugin registers an admin controller, `cabana.Activate` returns nil and no admin routes exist. `surf.Router.AdminPrefix` reports which case applies, and the `serve` and `route:list` commands print it as an admin status line. Record and user lookups use the `*gorm.DB` that [lagoon](../lagoon/README.md) publishes on the `backpack.App`.
## API reference

View File

@@ -22,7 +22,7 @@ surf turns the routes that plugins declare through `pact.HasRoutes` into one `ht
- Path-scoped CORS configured with the same keys as Laravel's `config/cors.php` (`surf.CORSConfig`), including preflight handling. Every `OPTIONS` request on a CORS path is answered with 204 before routing, with the headers Laravel's `HandleCors` sends: `Cache-Control: no-cache, private` always and, on a preflight (an `Origin` and an `Access-Control-Request-Method`), the requested method (upper-cased) and headers echoed in `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` when `*` allows any, `Vary` on the request headers and PHP's default `Content-Type: text/html; charset=UTF-8`.
- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay.
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
- A read-only route table (`surf.Router.Routes`) and the `serve` and `route:list` commands.
- A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands.
## Usage
@@ -108,6 +108,7 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
| `surf.Router.RegisterMiddleware` | Stores a named middleware; duplicates fail. |
| `surf.Router.RegisterMiddlewareFactory` | Stores a parameterized middleware used as `name:param`. |
| `surf.Router.Routes` | Returns a copy of the registered routes as `surf.RouteInfo` values. |
| `surf.Router.AdminPrefix` | The admin mount path and true when `surf.BuildRouter` mounted the cabana admin; false when no plugin registers an admin controller. |
| `surf.RouteInfo` | Method, pattern, owning plugin, middleware and raw flag of one route. |
| `surf.Use` | Builds a middleware name list for a group. |
| `surf.Constraint` | A compiled path-parameter restriction built by `surf.Regex` or `surf.Enum`. |
@@ -162,8 +163,8 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md
| Command | Flags | Description |
|---------|-------|-------------|
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. |
| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route. |
| `serve` | `--addr` (default `:8080`) | Opens the database and uploads bucket, assembles the router, binds the address, starts the in-process job worker (see `queue.work_in_serve`) and serves HTTP until SIGINT or SIGTERM, then shuts the server and the worker down gracefully within 10 seconds. After binding it prints the listen address with a browser URL (an unspecified host shows as 127.0.0.1) and an admin status line: the admin URL, with a warning naming `summer admin:create` when no administrator exists yet, or `admin: off` with the steps to add an admin controller. |
| `route:list` | none | Builds the router the same way `serve` does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route, followed by the admin status line (`admin: mounted at <prefix>` or `admin: off`). |
## Dependencies

View File

@@ -1,6 +1,7 @@
package surf
import (
"bytes"
"io/fs"
"net/http"
"os"
@@ -10,6 +11,7 @@ import (
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
@@ -177,3 +179,57 @@ func (p prefixRoutePlugin) Routes(r pact.Router) error {
})
return nil
}
// TestRouterAdminPrefix reports the admin mount path BuildRouter used.
func TestRouterAdminPrefix(t *testing.T) {
for _, tc := range []struct {
name string
app *backpack.App
plugins []party.Plugin
prefix string
mounted bool
}{
{"configured prefix", adminPrefixApp(t), []party.Plugin{adminPrefixPlugin{}}, "/acme-admin", true},
{"default prefix", adminPrefixAppWith(t, ""), []party.Plugin{adminPrefixPlugin{}}, "/backend", true},
{"no admin controllers", adminPrefixApp(t), []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/hello"}}, "", false},
} {
t.Run(tc.name, func(t *testing.T) {
r, err := BuildRouter(tc.app, tc.plugins)
if err != nil {
t.Fatal(err)
}
prefix, mounted := r.AdminPrefix()
if prefix != tc.prefix || mounted != tc.mounted {
t.Fatalf("AdminPrefix() = (%q, %v), want (%q, %v)", prefix, mounted, tc.prefix, tc.mounted)
}
})
}
if prefix, mounted := (*Router)(nil).AdminPrefix(); prefix != "" || mounted {
t.Fatalf("nil router AdminPrefix() = (%q, %v)", prefix, mounted)
}
}
// TestRouteListAdminStatus ends the route table with the admin status line.
func TestRouteListAdminStatus(t *testing.T) {
for _, tc := range []struct {
name string
app *backpack.App
plugins []party.Plugin
want string
}{
{"mounted", adminPrefixApp(t), []party.Plugin{adminPrefixPlugin{}}, "admin: mounted at /acme-admin"},
{"off", adminPrefixApp(t), []party.Plugin{prefixRoutePlugin{id: "acme.site", method: http.MethodGet, path: "/hello"}}, "admin: off — no plugin registers an admin controller"},
} {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
if err := RouteListCommand(tc.app, tc.plugins).Run(t.Context(), nil, out); err != nil {
t.Fatal(err)
}
got := strings.TrimRight(buf.String(), "\n")
if !strings.HasSuffix(got, tc.want) {
t.Fatalf("route:list output does not end with %q:\n%s", tc.want, got)
}
})
}
}

View File

@@ -11,7 +11,8 @@ import (
)
// RouteListCommand builds the router the same way ServeCommand does, then
// renders Routes() as a table instead of serving.
// renders Routes() as a table instead of serving, followed by the admin
// status line.
func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
return bonfire.Command{
Name: "route:list",
@@ -33,6 +34,11 @@ func RouteListCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command
})
}
out.Table([]string{"Method", "Pattern", "Plugin", "Middleware", "Raw"}, rows)
if prefix, mounted := r.AdminPrefix(); mounted {
out.Info("admin: mounted at " + prefix)
} else {
out.Info(adminOffText)
}
return nil
},
}

View File

@@ -60,6 +60,8 @@ type Router struct {
uploadBytes int64
built map[string]pact.Middleware
app *backpack.App
adminPrefix string
adminMounted bool
}
var (
@@ -556,6 +558,8 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
if err := r.checkAdminPrefix(admin.Prefix); err != nil {
return nil, err
}
r.adminPrefix = admin.Prefix
r.adminMounted = true
}
for _, rt := range r.routes {
if _, err := r.wrap(rt); err != nil {

View File

@@ -10,6 +10,15 @@ type RouteInfo struct {
Raw bool
}
// AdminPrefix reports the cabana admin mount path set by BuildRouter. False
// means no plugin registers an admin controller, so no admin routes exist.
func (r *Router) AdminPrefix() (string, bool) {
if r == nil || !r.adminMounted {
return "", false
}
return r.adminPrefix, true
}
// Routes returns a defensive copy of every registered route.
func (r *Router) Routes() []RouteInfo {
if r == nil {

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"os"
"os/signal"
"strings"
@@ -13,11 +14,13 @@ import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/party"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// ServeCommand starts a signal-aware HTTP server on the assembled router.
@@ -50,22 +53,36 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
return err
}
defer bucket.Close()
h, err := Assemble(app, plugins)
r, err := BuildRouter(app, plugins)
if err != nil {
return err
}
h, err := r.compile()
if err != nil {
return err
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
worker, err := conga.StartServeWorker(ctx, app, plugins)
if err != nil {
_ = ln.Close()
return err
}
if worker != nil {
out.Info(fmt.Sprintf("job worker started on queues: %s", strings.Join(worker.Queues(), ", ")))
}
prefix, mounted := r.AdminPrefix()
admins := adminsUnknown
if mounted {
admins = countAdmins(ctx, gdb)
}
printBanner(out, serveBanner(addr, prefix, mounted, admins))
srv := &http.Server{Addr: addr, Handler: h, BaseContext: func(net.Listener) context.Context { return ctx }}
errCh := make(chan error, 1)
go func() {
out.Info(fmt.Sprintf("listening on %s", addr))
errCh <- srv.ListenAndServe()
errCh <- srv.Serve(ln)
}()
shutdownCtx := func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
@@ -121,3 +138,119 @@ func publishUploads(ctx context.Context, app *backpack.App) (*blob.Bucket, error
}
return bucket, nil
}
// adminCreateCommand is the command the startup hints name for creating the
// first backend administrator.
const adminCreateCommand = "summer admin:create --email you@example.com --superuser"
// adminOffText is the admin status line serve and route:list print when no
// plugin registers an admin controller.
const adminOffText = "admin: off — no plugin registers an admin controller"
// serveURL turns a listen address into a URL a browser can open. An empty or
// unspecified host (0.0.0.0, ::) becomes 127.0.0.1, port 80 is left out and
// an IPv6 host is bracketed. An address that does not parse gives "".
func serveURL(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil || port == "" {
return ""
}
if host == "" {
host = "127.0.0.1"
} else if ip, err := netip.ParseAddr(host); err == nil && ip.IsUnspecified() {
host = "127.0.0.1"
}
hostport := net.JoinHostPort(host, port)
if port == "80" {
hostport = host
if strings.Contains(host, ":") {
hostport = "[" + host + "]"
}
}
return "http://" + hostport + "/"
}
// adminPresence is what serve could learn about backend administrators.
type adminPresence int
const (
adminsUnknown adminPresence = iota
adminsMissingTable
adminsNone
adminsPresent
)
// countAdmins checks whether backend_users exists and holds a row. It is
// bounded by a 2 second timeout and maps every failure to adminsUnknown, so
// it never fails or blocks boot. HasTable queries information_schema, so a
// missing table does not reach the GORM error log.
func countAdmins(ctx context.Context, gdb *gorm.DB) adminPresence {
if gdb == nil {
return adminsUnknown
}
c, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
db := gdb.WithContext(c)
if !db.Migrator().HasTable(&cabana.BackendUser{}) {
if c.Err() != nil {
return adminsUnknown
}
return adminsMissingTable
}
var n int64
if err := db.Model(&cabana.BackendUser{}).Count(&n).Error; err != nil {
return adminsUnknown
}
if n == 0 {
return adminsNone
}
return adminsPresent
}
// bannerLine is one startup line; warn lines print with the warning symbol.
type bannerLine struct {
warn bool
text string
}
// serveBanner builds the lines serve prints once the port is bound: the
// listen address with a browser URL, then the admin status.
func serveBanner(addr, adminPrefix string, mounted bool, admins adminPresence) []bannerLine {
url := serveURL(addr)
listen := "listening on " + addr
if url != "" {
listen += ", open " + url
}
lines := []bannerLine{{text: listen}}
if !mounted {
return append(lines,
bannerLine{text: adminOffText},
bannerLine{text: "to add one, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + adminCreateCommand + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"},
)
}
if url != "" {
lines = append(lines, bannerLine{text: "admin: " + strings.TrimSuffix(url, "/") + adminPrefix})
} else {
lines = append(lines, bannerLine{text: "admin: mounted at " + adminPrefix})
}
switch admins {
case adminsNone:
lines = append(lines, bannerLine{warn: true, text: "no administrators yet; create one with " + adminCreateCommand})
case adminsMissingTable:
lines = append(lines, bannerLine{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + adminCreateCommand})
case adminsUnknown:
lines = append(lines, bannerLine{text: "sign in with an administrator; " + adminCreateCommand + " creates one"})
}
return lines
}
// printBanner writes banner lines as info or warning output.
func printBanner(out bonfire.Output, lines []bannerLine) {
for _, l := range lines {
if l.warn {
out.Warning(l.text)
continue
}
out.Info(l.text)
}
}

View File

@@ -1,12 +1,15 @@
package surf
import (
"bytes"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
"gocloud.dev/blob"
)
@@ -51,3 +54,71 @@ func serveTestApp(t *testing.T, storageYAML string) *backpack.App {
}
return backpack.New(cfg)
}
func TestServeURL(t *testing.T) {
for _, tc := range []struct{ addr, want string }{
{":8123", "http://127.0.0.1:8123/"},
{"127.0.0.1:9000", "http://127.0.0.1:9000/"},
{"[::1]:8080", "http://[::1]:8080/"},
{"0.0.0.0:80", "http://127.0.0.1/"},
{"[::]:8080", "http://127.0.0.1:8080/"},
{"localhost:3000", "http://localhost:3000/"},
{"8080", ""},
{"", ""},
{"127.0.0.1:", ""},
} {
if got := serveURL(tc.addr); got != tc.want {
t.Errorf("serveURL(%q) = %q, want %q", tc.addr, got, tc.want)
}
}
}
func TestServeBanner(t *testing.T) {
listen := bannerLine{text: "listening on :8123, open http://127.0.0.1:8123/"}
mounted := bannerLine{text: "admin: http://127.0.0.1:8123/backend"}
create := "summer admin:create --email you@example.com --superuser"
for _, tc := range []struct {
name string
addr string
prefix string
mounted bool
admins adminPresence
want []bannerLine
}{
{"present", ":8123", "/backend", true, adminsPresent, []bannerLine{listen, mounted}},
{"none", ":8123", "/backend", true, adminsNone, []bannerLine{listen, mounted,
{warn: true, text: "no administrators yet; create one with " + create}}},
{"missing table", ":8123", "/backend", true, adminsMissingTable, []bannerLine{listen, mounted,
{warn: true, text: "the admin tables do not exist yet; run summer migrate, then " + create}}},
{"unknown", ":8123", "/backend", true, adminsUnknown, []bannerLine{listen, mounted,
{text: "sign in with an administrator; " + create + " creates one"}}},
{"off", ":8123", "", false, adminsUnknown, []bannerLine{listen,
{text: "admin: off — no plugin registers an admin controller"},
{text: "to add one, run summer make:admin-controller <vendor.plugin> <Name>, then summer build, summer migrate and " + create + "; the admin needs SUMMER_ADMIN__JWT__SECRET, which summer new writes to .env"}}},
{"unparsable address", "8080", "/backend", true, adminsPresent, []bannerLine{
{text: "listening on 8080"}, {text: "admin: mounted at /backend"}}},
} {
t.Run(tc.name, func(t *testing.T) {
got := serveBanner(tc.addr, tc.prefix, tc.mounted, tc.admins)
if !slices.Equal(got, tc.want) {
t.Fatalf("serveBanner:\n got %#v\nwant %#v", got, tc.want)
}
})
}
}
func TestPrintBanner(t *testing.T) {
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
printBanner(out, []bannerLine{{text: "plain"}, {warn: true, text: "careful"}})
got := buf.String()
if !strings.Contains(got, "ℹ plain") || !strings.Contains(got, "⚠ careful") {
t.Fatalf("printBanner output = %q", got)
}
}
func TestCountAdminsNilDB(t *testing.T) {
if got := countAdmins(t.Context(), nil); got != adminsUnknown {
t.Fatalf("countAdmins(nil) = %v, want adminsUnknown", got)
}
}