282 lines
8.0 KiB
Go
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)
|
|
}
|