Files
plainleaf/server/runtime_api.go
T

282 lines
8.0 KiB
Go

package server
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
)
var runtimeAPIRequestsTotal = prometheus.NewCounter(prometheus.CounterOpts{
Name: "silverbullet_runtime_api_requests_total",
Help: "Total number of runtime API requests received",
})
func init() {
prometheus.MustRegister(runtimeAPIRequestsTotal)
}
// RuntimeBridge manages the headless browser for Runtime API requests.
type RuntimeBridge struct {
mu sync.Mutex
config *HeadlessConfig // nil = headless disabled
browser *HeadlessBrowser // nil = not started yet
starting chan struct{} // non-nil while browser is starting; closed when done
startErr error // error from most recent start attempt
}
// NewRuntimeBridge creates a new RuntimeBridge. Pass nil config to disable headless.
func NewRuntimeBridge(config *HeadlessConfig) *RuntimeBridge {
return &RuntimeBridge{
config: config,
}
}
// ensureLaunched starts the headless browser if not already launched.
// Returns as soon as the browser process is running and collecting logs,
// but the client may not be fully ready for eval yet.
func (b *RuntimeBridge) ensureLaunched(ctx context.Context) error {
if b.config == nil {
return nil // headless disabled, nothing to start
}
b.mu.Lock()
// Already running?
if b.browser != nil && b.browser.ctx.Err() == nil {
b.mu.Unlock()
return nil
}
// Another goroutine is already starting?
if b.starting != nil {
ch := b.starting
b.mu.Unlock()
select {
case <-ch:
return b.startErr
case <-ctx.Done():
return ctx.Err()
}
}
// We are the starter
b.starting = make(chan struct{})
b.mu.Unlock()
hb, err := StartHeadlessBrowser(b.config)
b.mu.Lock()
b.startErr = err
if err == nil {
b.browser = hb
}
close(b.starting)
b.starting = nil
b.mu.Unlock()
return err
}
// EnsureRunning starts the headless browser and waits for the client to be fully ready.
func (b *RuntimeBridge) EnsureRunning(ctx context.Context) error {
if err := b.ensureLaunched(ctx); err != nil {
return err
}
browser := b.getBrowser()
if browser == nil {
return nil
}
return browser.WaitReady(ctx)
}
// SetBrowser sets the headless browser instance on the bridge (used in tests).
func (b *RuntimeBridge) SetBrowser(hb *HeadlessBrowser) {
b.mu.Lock()
defer b.mu.Unlock()
b.browser = hb
}
// Stop shuts down the headless browser if running.
func (b *RuntimeBridge) Stop() {
b.mu.Lock()
browser := b.browser
b.browser = nil
b.mu.Unlock()
if browser != nil {
browser.Stop()
}
}
// parseTimeout reads the X-Timeout header (seconds) and returns a duration, defaulting to 30s.
func parseTimeout(r *http.Request) time.Duration {
if v := r.Header.Get("X-Timeout"); v != "" {
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
}
return 30 * time.Second
}
// writeJSON writes a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// getBrowser returns the current browser instance, or nil.
func (b *RuntimeBridge) getBrowser() *HeadlessBrowser {
b.mu.Lock()
defer b.mu.Unlock()
return b.browser
}
// withBridge ensures the headless browser is up (if configured), then runs fn
// with a per-request timeout. Writes the appropriate error response when the
// bridge isn't available, the browser failed to start, or fn timed out.
// Returns false if the response was already written.
func (b *RuntimeBridge) withBridge(
w http.ResponseWriter,
r *http.Request,
fn func(ctx context.Context, browser *HeadlessBrowser) (any, error),
) (any, bool) {
runtimeAPIRequestsTotal.Inc()
if b.config != nil {
if err := b.EnsureRunning(r.Context()); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"error": fmt.Sprintf("Failed to start headless browser: %v", err),
"code": "bridge_unavailable",
})
return nil, false
}
}
browser := b.getBrowser()
if browser == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"error": "No headless browser running",
"code": "bridge_unavailable",
})
return nil, false
}
timeout := parseTimeout(r)
ctx, cancel := context.WithTimeout(browser.ctx, timeout)
defer cancel()
res, err := fn(ctx, browser)
if err != nil {
if ctx.Err() != nil {
writeJSON(w, http.StatusGatewayTimeout, map[string]any{
"error": "Request timed out", "code": "timeout",
})
return nil, false
}
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(), "code": "internal_error",
})
return nil, false
}
return res, true
}
// evalAndRespond evaluates Lua via CDP and writes the HTTP response.
func (b *RuntimeBridge) evalAndRespond(w http.ResponseWriter, r *http.Request, fnName string, code string) {
res, ok := b.withBridge(w, r, func(ctx context.Context, browser *HeadlessBrowser) (any, error) {
return browser.evalViaGlobal(ctx, fnName, code)
})
if !ok {
return
}
writeJSON(w, http.StatusOK, map[string]any{"result": res})
}
// HandleScreenshot captures a screenshot from the headless browser and returns it as PNG.
func (b *RuntimeBridge) HandleScreenshot(w http.ResponseWriter, r *http.Request) {
if err := b.EnsureRunning(r.Context()); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": fmt.Sprintf("Failed to start headless browser: %v", err)})
return
}
browser := b.getBrowser()
if browser == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "No headless browser running"})
return
}
png, err := browser.Screenshot()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "image/png")
w.Write(png)
}
// HandleConsoleLogs returns recent console log entries from the headless browser.
// Unlike other runtime endpoints, this does not wait for the client to be fully ready,
// so it can return boot logs while the client is still loading.
func (b *RuntimeBridge) HandleConsoleLogs(w http.ResponseWriter, r *http.Request) {
if err := b.ensureLaunched(r.Context()); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": fmt.Sprintf("Failed to start headless browser: %v", err)})
return
}
browser := b.getBrowser()
if browser == nil {
writeJSON(w, http.StatusOK, map[string]any{"logs": []ConsoleLogEntry{}})
return
}
limit := 100
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
var since int64
if v := r.URL.Query().Get("since"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
since = n
}
}
logs := browser.Logs(limit, since)
writeJSON(w, http.StatusOK, map[string]any{"logs": logs})
}
// HandleLuaAPI handles POST /.runtime/lua — evaluates a Lua expression.
// Request body: raw Lua expression as plain text.
func (b *RuntimeBridge) HandleLuaAPI(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Failed to read request body"})
return
}
expr := strings.TrimSpace(string(body))
if expr == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Request body is required"})
return
}
b.evalAndRespond(w, r, "sbRuntime.evalLua", expr)
}
// HandleLuaScriptAPI handles POST /.runtime/lua_script — evaluates a Lua script block.
// Request body: raw Lua script as plain text.
func (b *RuntimeBridge) HandleLuaScriptAPI(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Failed to read request body"})
return
}
script := strings.TrimSpace(string(body))
if script == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Request body is required"})
return
}
b.evalAndRespond(w, r, "sbRuntime.evalLuaScript", script)
}