0 {
+ return strings.TrimPrefix(names[0], "/")
+ }
+ id := asString(container["Id"])
+ if len(id) > 12 {
+ id = id[:12]
+ }
+ return id
+}
+
+func (w *DockerWatcher) containerToSlug(container map[string]any) string {
+ name := w.getContainerName(container)
+ return strings.NewReplacer("_", "-", ".", "-").Replace(name)
+}
+
+func (w *DockerWatcher) addContainer(container map[string]any) {
+ slug := w.containerToSlug(container)
+ name := w.getContainerName(container)
+ command := w.getContainerCommand(container)
+ theme := w.getContainerTheme(container)
+ containerID := asString(container["Id"])
+
+ w.mu.Lock()
+ if _, exists := w.managed[slug]; exists {
+ w.mu.Unlock()
+ return
+ }
+ w.managed[slug] = containerID
+ w.mu.Unlock()
+
+ w.sessionManager.AddApp(name, command, slug, true, theme)
+ if w.onContainerAdded != nil {
+ w.onContainerAdded(slug, name, command)
+ }
+}
+
+func (w *DockerWatcher) removeContainer(containerID string) {
+ w.mu.Lock()
+ slug := ""
+ for s, id := range w.managed {
+ if id == containerID || strings.HasPrefix(id, containerID) {
+ slug = s
+ delete(w.managed, s)
+ break
+ }
+ }
+ w.mu.Unlock()
+ if slug == "" {
+ return
+ }
+
+ if sessionID, ok := w.sessionManager.GetSessionIDByRouteKey(slug); ok {
+ w.sessionManager.CloseSession(sessionID)
+ }
+ w.sessionManager.RemoveApp(slug)
+ if w.onContainerRemoved != nil {
+ w.onContainerRemoved(slug)
+ }
+}
+
+func (w *DockerWatcher) listLabeledContainers() ([]map[string]any, error) {
+ seen := map[string]bool{}
+ containers := []map[string]any{}
+ for _, label := range []string{WebtermLabelName, WebtermThemeLabel} {
+ path := fmt.Sprintf(`/containers/json?filters={"label":["%s"]}`, label)
+ status, body, err := unixJSONRequest(w.socketPath, http.MethodGet, path, nil)
+ if err != nil || status != http.StatusOK {
+ continue
+ }
+ var payload []map[string]any
+ if err := json.Unmarshal(body, &payload); err != nil {
+ continue
+ }
+ for _, container := range payload {
+ id := asString(container["Id"])
+ if id == "" || seen[id] {
+ continue
+ }
+ seen[id] = true
+ containers = append(containers, container)
+ }
+ }
+ return containers, nil
+}
+
+func (w *DockerWatcher) handleEvent(event map[string]any) {
+ action := asString(event["Action"])
+ actor := toAnyMap(event["Actor"])
+ containerID := asString(actor["ID"])
+ if containerID == "" {
+ return
+ }
+ switch action {
+ case "start":
+ path := fmt.Sprintf("/containers/%s/json", url.PathEscape(containerID))
+ status, body, err := unixJSONRequest(w.socketPath, http.MethodGet, path, nil)
+ if err != nil || status != http.StatusOK {
+ return
+ }
+ var detail map[string]any
+ if err := json.Unmarshal(body, &detail); err != nil {
+ return
+ }
+ config := toAnyMap(detail["Config"])
+ labels := toStringMap(config["Labels"])
+ if !hasWebtermLabel(labels) {
+ return
+ }
+ container := map[string]any{
+ "Id": containerID,
+ "Names": []any{"/" + strings.TrimPrefix(asString(detail["Name"]), "/")},
+ "Labels": config["Labels"],
+ }
+ w.addContainer(container)
+ case "die":
+ w.removeContainer(containerID)
+ }
+}
+
+func (w *DockerWatcher) watchEvents(ctx context.Context) {
+ defer close(w.waitDone)
+ filters := url.QueryEscape(`{"event":["start","die"],"type":["container"]}`)
+ requestURL := "http://unix/events?filters=" + filters
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
+ resp, err := w.httpClient.Do(req)
+ if err != nil {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(5 * time.Second):
+ continue
+ }
+ }
+ decoder := json.NewDecoder(resp.Body)
+ for {
+ var event map[string]any
+ if err := decoder.Decode(&event); err != nil {
+ break
+ }
+ w.handleEvent(event)
+ }
+ _ = resp.Body.Close()
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(2 * time.Second):
+ }
+ }
+}
+
+func (w *DockerWatcher) ScanExisting() {
+ containers, err := w.listLabeledContainers()
+ if err != nil {
+ return
+ }
+ for _, container := range containers {
+ w.addContainer(container)
+ }
+}
+
+func (w *DockerWatcher) Start() {
+ w.mu.Lock()
+ if w.running {
+ w.mu.Unlock()
+ return
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ w.cancel = cancel
+ w.running = true
+ w.mu.Unlock()
+ w.ScanExisting()
+ go w.watchEvents(ctx)
+}
+
+func (w *DockerWatcher) Stop() {
+ w.mu.Lock()
+ if !w.running {
+ w.mu.Unlock()
+ return
+ }
+ w.running = false
+ cancel := w.cancel
+ w.mu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
+ <-w.waitDone
+}
diff --git a/go/webterm/docker_watcher_test.go b/go/webterm/docker_watcher_test.go
new file mode 100644
index 0000000..f8f35a6
--- /dev/null
+++ b/go/webterm/docker_watcher_test.go
@@ -0,0 +1,23 @@
+package webterm
+
+import "testing"
+
+func TestDockerWatcherCommandAndThemeParsing(t *testing.T) {
+ watcher := NewDockerWatcher(NewSessionManager(nil), "/tmp/docker.sock", nil, nil)
+ container := map[string]any{
+ "Labels": map[string]any{
+ WebtermLabelName: "auto",
+ WebtermThemeLabel: "nord",
+ },
+ "Names": []any{"/my_container.1"},
+ }
+ if cmd := watcher.getContainerCommand(container); cmd != AutoCommandSentinel {
+ t.Fatalf("expected auto command sentinel, got %q", cmd)
+ }
+ if theme := watcher.getContainerTheme(container); theme != "nord" {
+ t.Fatalf("unexpected theme: %q", theme)
+ }
+ if slug := watcher.containerToSlug(container); slug != "my-container-1" {
+ t.Fatalf("unexpected slug: %q", slug)
+ }
+}
diff --git a/go/webterm/identity.go b/go/webterm/identity.go
new file mode 100644
index 0000000..a162ef2
--- /dev/null
+++ b/go/webterm/identity.go
@@ -0,0 +1,36 @@
+package webterm
+
+import (
+ "crypto/rand"
+)
+
+const (
+ identityAlphabet = "0123456789ABCDEFGHJKMNPQRSTUVWYZ"
+ identitySize = 12
+)
+
+func GenerateID(size int) string {
+ if size <= 0 {
+ size = identitySize
+ }
+ const alphabetLen = len(identityAlphabet) // 31
+ // Largest multiple of 31 that fits in a byte: 31*8 = 248
+ const maxUnbiased = alphabetLen * (256 / alphabetLen) // 248
+ out := make([]byte, size)
+ buf := make([]byte, size+16) // extra bytes for rejection sampling
+ filled := 0
+ for filled < size {
+ _, _ = rand.Read(buf)
+ for _, b := range buf {
+ if int(b) >= maxUnbiased {
+ continue // reject to avoid modulo bias
+ }
+ out[filled] = identityAlphabet[int(b)%alphabetLen]
+ filled++
+ if filled >= size {
+ break
+ }
+ }
+ }
+ return string(out)
+}
diff --git a/go/webterm/normalize.go b/go/webterm/normalize.go
new file mode 100644
index 0000000..6b42b82
--- /dev/null
+++ b/go/webterm/normalize.go
@@ -0,0 +1,97 @@
+package webterm
+
+import (
+ "bytes"
+ "regexp"
+)
+
+var (
+ daResponsePattern = regexp.MustCompile(`\x1b\[[?>=][\d;]*c`)
+ daPartialPattern = regexp.MustCompile(`\x1b(?:\[(?:[?>=][\d;]*)?)?$`)
+)
+
+func NormalizeC1Controls(data []byte, utf8Buffer []byte) ([]byte, []byte) {
+ if len(data) == 0 && len(utf8Buffer) == 0 {
+ return nil, nil
+ }
+ merged := append(append([]byte{}, utf8Buffer...), data...)
+ out := make([]byte, 0, len(merged))
+ pending := make([]byte, 0, 4)
+ expectedContinuations := 0
+
+ c1Map := map[byte][]byte{
+ 0x9B: []byte("\x1b["),
+ 0x9D: []byte("\x1b]"),
+ 0x9C: []byte("\x1b\\"),
+ 0x90: []byte("\x1bP"),
+ 0x98: []byte("\x1bX"),
+ 0x9E: []byte("\x1b^"),
+ 0x9F: []byte("\x1b_"),
+ }
+
+ for i := 0; i < len(merged); {
+ b := merged[i]
+ if expectedContinuations > 0 {
+ if b >= 0x80 && b <= 0xBF {
+ pending = append(pending, b)
+ expectedContinuations--
+ i++
+ if expectedContinuations == 0 {
+ out = append(out, pending...)
+ pending = pending[:0]
+ }
+ continue
+ }
+ out = append(out, pending...)
+ pending = pending[:0]
+ expectedContinuations = 0
+ continue
+ }
+ switch {
+ case b >= 0xC2 && b <= 0xDF:
+ pending = append(pending, b)
+ expectedContinuations = 1
+ i++
+ continue
+ case b >= 0xE0 && b <= 0xEF:
+ pending = append(pending, b)
+ expectedContinuations = 2
+ i++
+ continue
+ case b >= 0xF0 && b <= 0xF4:
+ pending = append(pending, b)
+ expectedContinuations = 3
+ i++
+ continue
+ }
+ if replacement, ok := c1Map[b]; ok {
+ out = append(out, replacement...)
+ } else {
+ out = append(out, b)
+ }
+ i++
+ }
+ if len(pending) > 0 {
+ return out, pending
+ }
+ return out, nil
+}
+
+func FilterDASequences(data []byte, escapeBuffer []byte) ([]byte, []byte) {
+ merged := append(append([]byte{}, escapeBuffer...), data...)
+ if len(merged) == 0 {
+ return nil, nil
+ }
+ filtered := daResponsePattern.ReplaceAll(merged, nil)
+ if len(filtered) == 0 {
+ return nil, nil
+ }
+ match := daPartialPattern.FindIndex(filtered)
+ if match == nil {
+ return filtered, nil
+ }
+ if match[0] == len(filtered)-1 || bytes.HasPrefix(filtered[match[0]:], []byte("\x1b[")) || bytes.Equal(filtered[match[0]:], []byte("\x1b")) {
+ return filtered[:match[0]], filtered[match[0]:]
+ }
+ return filtered, nil
+}
diff --git a/go/webterm/normalize_test.go b/go/webterm/normalize_test.go
new file mode 100644
index 0000000..7a10225
--- /dev/null
+++ b/go/webterm/normalize_test.go
@@ -0,0 +1,55 @@
+package webterm
+
+import "testing"
+
+func TestNormalizeC1Controls(t *testing.T) {
+ input := []byte{0x9B, '3', '1', 'm', 'A'}
+ normalized, pending := NormalizeC1Controls(input, nil)
+ if string(pending) != "" {
+ t.Fatalf("expected no pending bytes, got %q", string(pending))
+ }
+ if string(normalized) != "\x1b[31mA" {
+ t.Fatalf("unexpected normalized output: %q", string(normalized))
+ }
+}
+
+func TestNormalizeC1ControlsPreservesSplitUTF8(t *testing.T) {
+ first := []byte{0xC3}
+ normalized, pending := NormalizeC1Controls(first, nil)
+ if len(normalized) != 0 {
+ t.Fatalf("expected no output for incomplete utf8")
+ }
+ second, pending2 := NormalizeC1Controls([]byte{0xA9}, pending)
+ if len(pending2) != 0 {
+ t.Fatalf("expected no pending bytes after completion")
+ }
+ if string(second) != "é" {
+ t.Fatalf("unexpected utf8 output: %q", string(second))
+ }
+}
+
+func TestFilterDASequencesCompleteAndPartial(t *testing.T) {
+ data := []byte("a\x1b[?1;10;0cb")
+ filtered, buffer := FilterDASequences(data, nil)
+ if string(buffer) != "" {
+ t.Fatalf("expected empty buffer, got %q", string(buffer))
+ }
+ if string(filtered) != "ab" {
+ t.Fatalf("unexpected filtered output: %q", string(filtered))
+ }
+
+ part1, partBuffer := FilterDASequences([]byte("x\x1b[?1;10"), nil)
+ if string(part1) != "x" {
+ t.Fatalf("unexpected part1 output: %q", string(part1))
+ }
+ if string(partBuffer) == "" {
+ t.Fatalf("expected buffered partial sequence")
+ }
+ part2, partBuffer2 := FilterDASequences([]byte(";0cy"), partBuffer)
+ if string(partBuffer2) != "" {
+ t.Fatalf("expected empty buffer after completion")
+ }
+ if string(part2) != "y" {
+ t.Fatalf("unexpected part2 output: %q", string(part2))
+ }
+}
diff --git a/go/webterm/replay.go b/go/webterm/replay.go
new file mode 100644
index 0000000..0688428
--- /dev/null
+++ b/go/webterm/replay.go
@@ -0,0 +1,51 @@
+package webterm
+
+import "sync"
+
+const replayBufferSize = 256 * 1024
+
+type ReplayBuffer struct {
+ mu sync.Mutex
+ parts [][]byte
+ size int
+ limit int
+}
+
+func NewReplayBuffer(limit int) *ReplayBuffer {
+ if limit <= 0 {
+ limit = replayBufferSize
+ }
+ return &ReplayBuffer{limit: limit}
+}
+
+func (r *ReplayBuffer) Add(data []byte) {
+ if len(data) == 0 {
+ return
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ chunk := append([]byte{}, data...)
+ r.parts = append(r.parts, chunk)
+ r.size += len(chunk)
+ evicted := 0
+ for r.size > r.limit && evicted < len(r.parts) {
+ r.size -= len(r.parts[evicted])
+ evicted++
+ }
+ if evicted > 0 {
+ // Copy remaining to a new slice to release old backing array
+ remaining := make([][]byte, len(r.parts)-evicted)
+ copy(remaining, r.parts[evicted:])
+ r.parts = remaining
+ }
+}
+
+func (r *ReplayBuffer) Bytes() []byte {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ joined := make([]byte, 0, r.size)
+ for _, chunk := range r.parts {
+ joined = append(joined, chunk...)
+ }
+ return joined
+}
diff --git a/go/webterm/replay_test.go b/go/webterm/replay_test.go
new file mode 100644
index 0000000..9281dc3
--- /dev/null
+++ b/go/webterm/replay_test.go
@@ -0,0 +1,13 @@
+package webterm
+
+import "testing"
+
+func TestReplayBufferTrimsOldData(t *testing.T) {
+ buffer := NewReplayBuffer(5)
+ buffer.Add([]byte("abc"))
+ buffer.Add([]byte("de"))
+ buffer.Add([]byte("f"))
+ if got := string(buffer.Bytes()); got != "def" {
+ t.Fatalf("expected trimmed replay buffer, got %q", got)
+ }
+}
diff --git a/go/webterm/server.go b/go/webterm/server.go
new file mode 100644
index 0000000..89cb276
--- /dev/null
+++ b/go/webterm/server.go
@@ -0,0 +1,834 @@
+package webterm
+
+import (
+ "context"
+ "crypto/sha1"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+const (
+ wsSendQueueMax = 256
+ wsSendTimeout = 2 * time.Second
+ stdinWriteTimeout = 2 * time.Second
+ screenshotCacheSeconds = 300 * time.Millisecond
+ maxScreenshotCacheTTL = 20 * time.Second
+)
+
+type ServerOptions struct {
+ Host string
+ Port int
+ Theme string
+ FontFamily string
+ FontSize int
+ LandingApps []App
+ ComposeMode bool
+ ComposeProject string
+ DockerWatch bool
+ StaticPath string
+}
+
+type screenshotCacheEntry struct {
+ when time.Time
+ svg string
+ etag string
+}
+
+type wsClient struct {
+ routeKey string
+ conn *websocket.Conn
+ send chan []byte
+ done chan struct{}
+ closed atomic.Bool
+}
+
+type LocalServer struct {
+ host string
+ port int
+ theme string
+ fontFamily string
+ fontSize int
+
+ sessionManager *SessionManager
+ landingApps []App
+ composeMode bool
+ composeProject string
+ dockerWatch bool
+ staticPath string
+
+ upgrader websocket.Upgrader
+
+ mu sync.RWMutex
+ wsClients map[string]*wsClient
+ screenshotCache map[string]screenshotCacheEntry
+ routeLastActivity map[string]time.Time
+ routeLastSSE map[string]time.Time
+ sseSubscribers map[chan string]struct{}
+ slugToService map[string]string
+ dockerStats *DockerStatsCollector
+ dockerWatcher *DockerWatcher
+ screenshotForceRedraw bool
+}
+
+type localClientConnector struct {
+ server *LocalServer
+ sessionID string
+ routeKey string
+}
+
+func (c *localClientConnector) OnData(data []byte) {
+ c.server.markRouteActivity(c.routeKey)
+ c.server.enqueueWSData(c.routeKey, data)
+}
+
+func (c *localClientConnector) OnBinary(payload []byte) {
+ c.server.markRouteActivity(c.routeKey)
+ c.server.enqueueWSData(c.routeKey, payload)
+}
+
+func (c *localClientConnector) OnMeta(_ map[string]any) {}
+
+func (c *localClientConnector) OnClose() {
+ c.server.sessionManager.OnSessionEnd(c.sessionID)
+ c.server.stopWSClient(c.routeKey)
+}
+
+func NewLocalServer(config Config, options ServerOptions) *LocalServer {
+ host := options.Host
+ if host == "" {
+ host = DefaultHost
+ }
+ port := options.Port
+ if port == 0 {
+ port = DefaultPort
+ }
+ theme := strings.TrimSpace(options.Theme)
+ if theme == "" {
+ theme = DefaultTheme
+ }
+ fontSize := options.FontSize
+ if fontSize <= 0 {
+ fontSize = DefaultFontSize
+ }
+ apps := append([]App{}, config.Apps...)
+ for _, app := range options.LandingApps {
+ apps = append(apps, app)
+ }
+ server := &LocalServer{
+ host: host,
+ port: port,
+ theme: theme,
+ fontFamily: options.FontFamily,
+ fontSize: fontSize,
+
+ sessionManager: NewSessionManager(apps),
+ landingApps: append([]App{}, options.LandingApps...),
+ composeMode: options.ComposeMode,
+ composeProject: options.ComposeProject,
+ dockerWatch: options.DockerWatch,
+ staticPath: options.StaticPath,
+ upgrader: websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+ },
+ wsClients: map[string]*wsClient{},
+ screenshotCache: map[string]screenshotCacheEntry{},
+ routeLastActivity: map[string]time.Time{},
+ routeLastSSE: map[string]time.Time{},
+ sseSubscribers: map[chan string]struct{}{},
+ slugToService: map[string]string{},
+ screenshotForceRedraw: EnvBool(ScreenshotForceRedrawEnv),
+ }
+ if server.staticPath == "" {
+ server.staticPath = findStaticPath()
+ }
+ return server
+}
+
+func findStaticPath() string {
+ if p := strings.TrimSpace(os.Getenv("WEBTERM_STATIC_PATH")); p != "" {
+ if stat, err := os.Stat(p); err == nil && stat.IsDir() {
+ return p
+ }
+ }
+ candidates := []string{
+ filepath.Join(".", "src", "webterm", "static"),
+ filepath.Join("..", "src", "webterm", "static"),
+ filepath.Join("..", "..", "src", "webterm", "static"),
+ }
+ for _, candidate := range candidates {
+ if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
+ return candidate
+ }
+ }
+ return ""
+}
+
+func (s *LocalServer) markRouteActivity(routeKey string) {
+ now := time.Now()
+ s.mu.Lock()
+ s.routeLastActivity[routeKey] = now
+ last := s.routeLastSSE[routeKey]
+ if now.Sub(last) >= 250*time.Millisecond {
+ s.routeLastSSE[routeKey] = now
+ for subscriber := range s.sseSubscribers {
+ select {
+ case subscriber <- routeKey:
+ default:
+ }
+ }
+ }
+ s.mu.Unlock()
+}
+
+func (s *LocalServer) enqueueWSData(routeKey string, data []byte) {
+ s.mu.RLock()
+ client := s.wsClients[routeKey]
+ s.mu.RUnlock()
+ if client == nil || client.closed.Load() {
+ return
+ }
+ payload := append([]byte{}, data...)
+ select {
+ case client.send <- payload:
+ default:
+ // Drop oldest, try again
+ select {
+ case <-client.send:
+ default:
+ }
+ select {
+ case client.send <- payload:
+ default:
+ }
+ }
+}
+
+func (s *LocalServer) stopWSClient(routeKey string) {
+ s.mu.Lock()
+ client := s.wsClients[routeKey]
+ delete(s.wsClients, routeKey)
+ s.mu.Unlock()
+ if client == nil {
+ return
+ }
+ client.closed.Store(true)
+ close(client.send)
+ <-client.done
+}
+
+func (s *LocalServer) wsSender(client *wsClient) {
+ defer close(client.done)
+ for payload := range client.send {
+ _ = client.conn.SetWriteDeadline(time.Now().Add(wsSendTimeout))
+ // Detect JSON messages (start with '[') vs binary terminal data
+ msgType := websocket.BinaryMessage
+ if len(payload) > 0 && payload[0] == '[' {
+ msgType = websocket.TextMessage
+ }
+ if err := client.conn.WriteMessage(msgType, payload); err != nil {
+ return
+ }
+ }
+}
+
+func (s *LocalServer) createTerminalSession(routeKey string, width, height int) error {
+ app, ok := s.sessionManager.AppBySlug(routeKey)
+ if !ok {
+ app, ok = s.sessionManager.GetDefaultApp()
+ if !ok {
+ return fmt.Errorf("no apps configured")
+ }
+ }
+ sessionID := GenerateID(identitySize)
+ session, err := s.sessionManager.NewSession(app.Slug, sessionID, routeKey, width, height)
+ if err != nil {
+ return err
+ }
+ connector := &localClientConnector{
+ server: s,
+ sessionID: sessionID,
+ routeKey: routeKey,
+ }
+ session.UpdateConnector(connector)
+ return session.Start(connector)
+}
+
+func clampInt(value, minValue, maxValue int) int {
+ if value < minValue {
+ return minValue
+ }
+ if value > maxValue {
+ return maxValue
+ }
+ return value
+}
+
+func parseResizePayload(value any) (int, int) {
+ width, height := 80, 24
+ payload, ok := value.(map[string]any)
+ if !ok {
+ return width, height
+ }
+ if raw, ok := payload["width"]; ok {
+ width = toInt(raw)
+ }
+ if raw, ok := payload["height"]; ok {
+ height = toInt(raw)
+ }
+ return clampInt(width, 1, 500), clampInt(height, 1, 500)
+}
+
+func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
+ routeKey := strings.TrimPrefix(r.URL.Path, "/ws/")
+ if routeKey == "" {
+ http.Error(w, "missing route key", http.StatusBadRequest)
+ return
+ }
+ conn, err := s.upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+
+ client := &wsClient{
+ routeKey: routeKey,
+ conn: conn,
+ send: make(chan []byte, wsSendQueueMax),
+ done: make(chan struct{}),
+ }
+ s.mu.Lock()
+ s.wsClients[routeKey] = client
+ s.mu.Unlock()
+ go s.wsSender(client)
+ defer s.stopWSClient(routeKey)
+
+ // Helper to send JSON through the send channel (avoids concurrent conn writes)
+ sendJSON := func(v any) {
+ data, err := json.Marshal(v)
+ if err != nil || client.closed.Load() {
+ return
+ }
+ select {
+ case client.send <- data:
+ default:
+ }
+ }
+
+ sessionCreated := false
+ sessionID, ok := s.sessionManager.GetSessionIDByRouteKey(routeKey)
+ if ok {
+ session := s.sessionManager.GetSession(sessionID)
+ if session != nil && session.IsRunning() {
+ sessionCreated = true
+ replay := daResponsePattern.ReplaceAll(session.GetReplayBuffer(), nil)
+ if len(replay) > 0 {
+ s.enqueueWSData(routeKey, replay)
+ }
+ } else {
+ s.sessionManager.OnSessionEnd(sessionID)
+ }
+ }
+
+ _ = conn.SetReadDeadline(time.Time{})
+ conn.SetPongHandler(func(string) error { return nil })
+
+ for {
+ messageType, payload, err := conn.ReadMessage()
+ if err != nil {
+ return
+ }
+ if messageType != websocket.TextMessage {
+ continue
+ }
+ var envelope []any
+ if err := json.Unmarshal(payload, &envelope); err != nil || len(envelope) == 0 {
+ continue
+ }
+ msgType, _ := envelope[0].(string)
+ switch msgType {
+ case "stdin":
+ s.markRouteActivity(routeKey)
+ session := s.sessionManager.GetSessionByRouteKey(routeKey)
+ if session != nil {
+ data := ""
+ if len(envelope) > 1 {
+ data, _ = envelope[1].(string)
+ }
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ _ = session.SendBytes([]byte(data))
+ }()
+ select {
+ case <-done:
+ case <-time.After(stdinWriteTimeout):
+ }
+ }
+ case "resize":
+ s.markRouteActivity(routeKey)
+ width, height := 80, 24
+ if len(envelope) > 1 {
+ width, height = parseResizePayload(envelope[1])
+ }
+ session := s.sessionManager.GetSessionByRouteKey(routeKey)
+ if session == nil {
+ if err := s.createTerminalSession(routeKey, width, height); err == nil {
+ sessionCreated = true
+ } else {
+ sendJSON([]any{"error", "Failed to create session"})
+ }
+ } else {
+ _ = session.SetTerminalSize(width, height)
+ s.mu.Lock()
+ delete(s.screenshotCache, routeKey)
+ s.mu.Unlock()
+ }
+ case "ping":
+ value := ""
+ if len(envelope) > 1 {
+ value, _ = envelope[1].(string)
+ }
+ sendJSON([]any{"pong", value})
+ }
+ if !sessionCreated && msgType == "resize" {
+ sessionCreated = true
+ }
+ }
+}
+
+func (s *LocalServer) chooseRouteForScreenshot(requested string) (string, Session, bool) {
+ if requested != "" {
+ session := s.sessionManager.GetSessionByRouteKey(requested)
+ if session != nil {
+ return requested, session, true
+ }
+ }
+ if requested == "" {
+ if routeKey, session, ok := s.sessionManager.GetFirstRunningSession(); ok {
+ return routeKey, session, true
+ }
+ }
+ return "", nil, false
+}
+
+func (s *LocalServer) screenshotTTL(routeKey string) time.Duration {
+ s.mu.RLock()
+ lastActivity := s.routeLastActivity[routeKey]
+ s.mu.RUnlock()
+ idle := time.Since(lastActivity)
+ switch {
+ case idle < 3*time.Second:
+ return screenshotCacheSeconds
+ case idle < 15*time.Second:
+ return 2 * time.Second
+ case idle < 120*time.Second:
+ return 5 * time.Second
+ default:
+ return maxScreenshotCacheTTL
+ }
+}
+
+func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
+ routeKey := r.URL.Query().Get("route_key")
+ routeKey, session, ok := s.chooseRouteForScreenshot(routeKey)
+ if !ok && routeKey != "" {
+ if _, exists := s.sessionManager.AppBySlug(routeKey); exists {
+ _ = s.createTerminalSession(routeKey, DefaultTerminalWidth, DefaultTerminalHeight)
+ time.Sleep(500 * time.Millisecond)
+ session = s.sessionManager.GetSessionByRouteKey(routeKey)
+ ok = session != nil
+ }
+ }
+ if !ok || session == nil {
+ http.Error(w, "Session not found", http.StatusNotFound)
+ return
+ }
+
+ s.mu.RLock()
+ cached, hasCached := s.screenshotCache[routeKey]
+ s.mu.RUnlock()
+ if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) {
+ if match := r.Header.Get("If-None-Match"); match != "" && match == cached.etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("ETag", cached.etag)
+ w.Header().Set("Content-Type", "image/svg+xml")
+ _, _ = io.WriteString(w, cached.svg)
+ return
+ }
+
+ if s.screenshotForceRedraw {
+ _ = session.ForceRedraw()
+ }
+
+ snapshot := session.GetScreenSnapshot()
+ if hasCached && !snapshot.HasChanges {
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("ETag", cached.etag)
+ w.Header().Set("Content-Type", "image/svg+xml")
+ _, _ = io.WriteString(w, cached.svg)
+ return
+ }
+
+ app, _ := s.sessionManager.AppBySlug(routeKey)
+ theme := strings.ToLower(strings.TrimSpace(app.Theme))
+ if theme == "" {
+ theme = strings.ToLower(s.theme)
+ }
+ palette := ThemePalettes[theme]
+ if palette == nil {
+ palette = ThemePalettes["xterm"]
+ }
+ background := palette["background"]
+ if background == "" {
+ background = ThemeBackgrounds["xterm"]
+ }
+ foreground := palette["foreground"]
+ if foreground == "" {
+ foreground = "#e5e5e5"
+ }
+
+ svg := RenderTerminalSVG(snapshot.Buffer, snapshot.Width, snapshot.Height, "webterm", background, foreground, palette)
+ hash := sha1.Sum([]byte(svg))
+ etag := fmt.Sprintf("%x", hash[:])
+ s.mu.Lock()
+ s.screenshotCache[routeKey] = screenshotCacheEntry{when: time.Now(), svg: svg, etag: etag}
+ s.mu.Unlock()
+
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("ETag", etag)
+ w.Header().Set("Content-Type", "image/svg+xml")
+ _, _ = io.WriteString(w, svg)
+}
+
+func (s *LocalServer) handleCPUSparkline(w http.ResponseWriter, r *http.Request) {
+ container := r.URL.Query().Get("container")
+ if strings.TrimSpace(container) == "" {
+ http.Error(w, "Missing container parameter", http.StatusBadRequest)
+ return
+ }
+ width := clampInt(toIntFromQuery(r.URL.Query().Get("width"), 100), 50, 300)
+ height := clampInt(toIntFromQuery(r.URL.Query().Get("height"), 20), 10, 100)
+
+ values := []float64{}
+ s.mu.RLock()
+ stats := s.dockerStats
+ serviceName := s.slugToService[container]
+ s.mu.RUnlock()
+ if serviceName == "" {
+ serviceName = container
+ }
+ if stats != nil {
+ values = stats.GetCPUHistory(serviceName)
+ }
+ w.Header().Set("Cache-Control", "no-cache, max-age=0")
+ w.Header().Set("Content-Type", "image/svg+xml")
+ _, _ = io.WriteString(w, RenderSparklineSVG(values, width, height))
+}
+
+func (s *LocalServer) handleEvents(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+ channel := make(chan string, 100)
+ s.mu.Lock()
+ s.sseSubscribers[channel] = struct{}{}
+ s.mu.Unlock()
+ defer func() {
+ s.mu.Lock()
+ delete(s.sseSubscribers, channel)
+ close(channel)
+ s.mu.Unlock()
+ }()
+
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+ notify := r.Context().Done()
+ for {
+ select {
+ case <-notify:
+ return
+ case routeKey := <-channel:
+ _, _ = fmt.Fprintf(w, "event: activity\ndata: %s\n\n", routeKey)
+ flusher.Flush()
+ case <-ticker.C:
+ _, _ = io.WriteString(w, ": keepalive\n\n")
+ flusher.Flush()
+ }
+ }
+}
+
+func toIntFromQuery(value string, fallback int) int {
+ if n, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
+ return n
+ }
+ return fallback
+}
+
+func (s *LocalServer) dashboardTiles() []map[string]string {
+ var apps []App
+ if s.dockerWatch {
+ apps = s.sessionManager.Apps()
+ } else {
+ apps = append([]App{}, s.landingApps...)
+ }
+ tiles := make([]map[string]string, 0, len(apps))
+ for _, app := range apps {
+ command := app.Command
+ if command == AutoCommandSentinel {
+ command = ""
+ }
+ tiles = append(tiles, map[string]string{
+ "slug": app.Slug,
+ "name": app.Name,
+ "command": command,
+ })
+ }
+ return tiles
+}
+
+func (s *LocalServer) handleTiles(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(s.dashboardTiles())
+}
+
+func (s *LocalServer) getWSURL(r *http.Request, routeKey string) string {
+ header := func(name string) string {
+ value := strings.TrimSpace(strings.Split(r.Header.Get(name), ",")[0])
+ return strings.ToLower(value)
+ }
+ forwardedProto := header("X-Forwarded-Proto")
+ forwardedHost := header("X-Forwarded-Host")
+ forwardedPort := header("X-Forwarded-Port")
+
+ wsProto := "ws"
+ if forwardedProto == "https" || forwardedProto == "wss" {
+ wsProto = "wss"
+ } else if forwardedProto == "" && r.TLS != nil {
+ wsProto = "wss"
+ }
+ host := forwardedHost
+ if host == "" {
+ host = r.Host
+ }
+ if host == "" {
+ if s.host == "0.0.0.0" {
+ host = "localhost"
+ } else {
+ host = s.host
+ }
+ if s.port != 80 && s.port != 443 {
+ host = fmt.Sprintf("%s:%d", host, s.port)
+ }
+ }
+ if forwardedPort != "" && !strings.Contains(host, ":") && forwardedPort != "80" && forwardedPort != "443" {
+ host += ":" + forwardedPort
+ }
+ return fmt.Sprintf("%s://%s/ws/%s", wsProto, host, routeKey)
+}
+
+func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
+ routeKeyParam := r.URL.Query().Get("route_key")
+ showDashboard := (len(s.landingApps) > 0 || s.dockerWatch) && routeKeyParam == ""
+ if showDashboard {
+ tilesJSON, _ := json.Marshal(s.dashboardTiles())
+ composeModeJS := "false"
+ if s.composeMode || s.dockerWatch {
+ composeModeJS = "true"
+ }
+ dockerWatchJS := "false"
+ if s.dockerWatch {
+ dockerWatchJS = "true"
+ }
+ html := fmt.Sprintf(`Session DashboardSessions
`, string(tilesJSON), composeModeJS, dockerWatchJS)
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = io.WriteString(w, html)
+ return
+ }
+
+ var app App
+ var ok bool
+ if routeKeyParam != "" {
+ app, ok = s.sessionManager.AppBySlug(routeKeyParam)
+ }
+ if !ok {
+ app, ok = s.sessionManager.GetDefaultApp()
+ }
+ if !ok {
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = io.WriteString(w, "Webterm ServerNo Apps Available
No terminal applications are configured.
")
+ return
+ }
+
+ routeKey := routeKeyParam
+ if routeKey == "" {
+ if runningKey, _, exists := s.sessionManager.GetFirstRunningSession(); exists {
+ routeKey = runningKey
+ } else {
+ routeKey = strings.ToLower(GenerateID(identitySize))
+ }
+ }
+ wsURL := s.getWSURL(r, routeKey)
+ theme := app.Theme
+ if strings.TrimSpace(theme) == "" {
+ theme = s.theme
+ }
+ themeBG := ThemeBackgrounds[strings.ToLower(theme)]
+ if themeBG == "" {
+ themeBG = "#000000"
+ }
+ fontFamily := s.fontFamily
+ if strings.TrimSpace(fontFamily) == "" {
+ fontFamily = "var(--webterm-mono)"
+ }
+ escapedFont := strings.ReplaceAll(fontFamily, `"`, """)
+ dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), s.fontSize, htmlAttrEscape(theme), escapedFont)
+ page := fmt.Sprintf(`%s`, htmlEscape(app.Name), themeBG, dataAttrs)
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = io.WriteString(w, page)
+}
+
+func htmlEscape(value string) string {
+ return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(value)
+}
+
+func htmlAttrEscape(value string) string {
+ return strings.NewReplacer("&", "&", `"`, """, "<", "<", ">", ">").Replace(value)
+}
+
+func (s *LocalServer) handleHealth(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "Local server is running")
+}
+
+func (s *LocalServer) setupDockerFeatures() {
+ if (s.composeMode && len(s.landingApps) > 0) || s.dockerWatch {
+ stats := NewDockerStatsCollector("", s.composeProject)
+ if stats.Available() {
+ serviceNames := []string{}
+ apps := s.landingApps
+ if s.dockerWatch {
+ apps = s.sessionManager.Apps()
+ }
+ for _, app := range apps {
+ serviceNames = append(serviceNames, app.Name)
+ s.slugToService[app.Slug] = app.Name
+ }
+ stats.Start(serviceNames)
+ s.dockerStats = stats
+ }
+ }
+ if s.dockerWatch {
+ watcher := NewDockerWatcher(
+ s.sessionManager,
+ "",
+ func(slug, name, _ string) {
+ s.mu.Lock()
+ s.slugToService[slug] = name
+ if s.dockerStats != nil {
+ s.dockerStats.AddService(name)
+ }
+ s.mu.Unlock()
+ s.markRouteActivity("__dashboard__")
+ },
+ func(slug string) {
+ s.mu.Lock()
+ serviceName := s.slugToService[slug]
+ delete(s.slugToService, slug)
+ delete(s.screenshotCache, slug)
+ if s.dockerStats != nil && serviceName != "" {
+ s.dockerStats.RemoveService(serviceName)
+ }
+ s.mu.Unlock()
+ s.markRouteActivity("__dashboard__")
+ },
+ )
+ s.dockerWatcher = watcher
+ watcher.Start()
+ }
+}
+
+func (s *LocalServer) shutdown() {
+ if s.dockerWatcher != nil {
+ s.dockerWatcher.Stop()
+ }
+ if s.dockerStats != nil {
+ s.dockerStats.Stop()
+ }
+ s.sessionManager.CloseAll()
+ s.mu.Lock()
+ clients := map[string]*wsClient{}
+ for key, client := range s.wsClients {
+ clients[key] = client
+ }
+ s.wsClients = map[string]*wsClient{}
+ s.mu.Unlock()
+ for _, client := range clients {
+ client.closed.Store(true)
+ close(client.send)
+ <-client.done
+ _ = client.conn.Close()
+ }
+}
+
+func (s *LocalServer) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/ws/", s.handleWebSocket)
+ mux.HandleFunc("/screenshot.svg", s.handleScreenshot)
+ mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline)
+ mux.HandleFunc("/events", s.handleEvents)
+ mux.HandleFunc("/health", s.handleHealth)
+ mux.HandleFunc("/tiles", s.handleTiles)
+ mux.HandleFunc("/", s.handleRoot)
+ if strings.TrimSpace(s.staticPath) != "" {
+ mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath))))
+ }
+ return mux
+}
+
+func (s *LocalServer) Run(ctx context.Context) error {
+ s.setupDockerFeatures()
+ server := &http.Server{
+ Addr: fmt.Sprintf("%s:%d", s.host, s.port),
+ Handler: s.Handler(),
+ }
+ errCh := make(chan error, 1)
+ go func() {
+ err := server.ListenAndServe()
+ if err != nil && err != http.ErrServerClosed {
+ errCh <- err
+ return
+ }
+ errCh <- nil
+ }()
+
+ select {
+ case <-ctx.Done():
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ _ = server.Shutdown(shutdownCtx)
+ cancel()
+ s.shutdown()
+ <-errCh
+ return nil
+ case err := <-errCh:
+ s.shutdown()
+ return err
+ }
+}
diff --git a/go/webterm/server_test.go b/go/webterm/server_test.go
new file mode 100644
index 0000000..35e1538
--- /dev/null
+++ b/go/webterm/server_test.go
@@ -0,0 +1,216 @@
+package webterm
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+func newServerForTests(t *testing.T, withLanding bool) (*LocalServer, *httptest.Server, *syncSessionMap) {
+ t.Helper()
+ config := Config{
+ Apps: []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}},
+ }
+ options := ServerOptions{}
+ if withLanding {
+ options.LandingApps = []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}}
+ }
+ server := NewLocalServer(config, options)
+ sessions := &syncSessionMap{m: map[string]*fakeSession{}}
+ server.sessionManager.SetSessionFactory(func(app App, sessionID string) Session {
+ s := newFakeSession()
+ sessions.mu.Lock()
+ sessions.m[sessionID] = s
+ sessions.mu.Unlock()
+ return s
+ })
+ httpServer := httptest.NewServer(server.Handler())
+ t.Cleanup(httpServer.Close)
+ return server, httpServer, sessions
+}
+
+type syncSessionMap struct {
+ mu sync.Mutex
+ m map[string]*fakeSession
+}
+
+func TestHealthAndTilesEndpoints(t *testing.T) {
+ _, httpServer, _ := newServerForTests(t, true)
+ resp, err := http.Get(httpServer.URL + "/health")
+ if err != nil {
+ t.Fatalf("health request error = %v", err)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if !strings.Contains(string(body), "Local server is running") {
+ t.Fatalf("unexpected health response: %q", string(body))
+ }
+
+ resp, err = http.Get(httpServer.URL + "/tiles")
+ if err != nil {
+ t.Fatalf("tiles request error = %v", err)
+ }
+ var tiles []map[string]string
+ if err := json.NewDecoder(resp.Body).Decode(&tiles); err != nil {
+ t.Fatalf("decode tiles: %v", err)
+ }
+ _ = resp.Body.Close()
+ if len(tiles) != 1 || tiles[0]["slug"] != "shell" {
+ t.Fatalf("unexpected tiles: %+v", tiles)
+ }
+}
+
+func TestWebSocketPingResizeAndStdin(t *testing.T) {
+ server, httpServer, sessions := newServerForTests(t, false)
+ wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell"
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("ws dial error = %v", err)
+ }
+ defer conn.Close()
+
+ if err := conn.WriteJSON([]any{"ping", "ok"}); err != nil {
+ t.Fatalf("write ping: %v", err)
+ }
+ _, payload, err := conn.ReadMessage()
+ if err != nil {
+ t.Fatalf("read pong: %v", err)
+ }
+ var pong []any
+ if err := json.Unmarshal(payload, &pong); err != nil {
+ t.Fatalf("decode pong: %v", err)
+ }
+ if pong[0] != "pong" || pong[1] != "ok" {
+ t.Fatalf("unexpected pong payload: %v", pong)
+ }
+
+ if err := conn.WriteJSON([]any{"resize", map[string]any{"width": 100, "height": 40}}); err != nil {
+ t.Fatalf("write resize: %v", err)
+ }
+ deadline := time.Now().Add(200 * time.Millisecond)
+ for time.Now().Before(deadline) && server.sessionManager.GetSessionByRouteKey("shell") == nil {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if session := server.sessionManager.GetSessionByRouteKey("shell"); session == nil {
+ t.Fatalf("expected session to be created on resize")
+ }
+
+ if err := conn.WriteJSON([]any{"stdin", "ls\n"}); err != nil {
+ t.Fatalf("write stdin: %v", err)
+ }
+ time.Sleep(20 * time.Millisecond)
+ found := false
+ sessions.mu.Lock()
+ for _, session := range sessions.m {
+ session.mu.Lock()
+ if len(session.received) > 0 && string(session.received[0]) == "ls\n" {
+ found = true
+ }
+ session.mu.Unlock()
+ }
+ sessions.mu.Unlock()
+ if !found {
+ t.Fatalf("expected stdin to reach session")
+ }
+}
+
+func TestWebSocketReplayOnReconnect(t *testing.T) {
+ _, httpServer, sessions := newServerForTests(t, false)
+ wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell"
+
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("first dial error = %v", err)
+ }
+ if err := conn.WriteJSON([]any{"resize", map[string]any{"width": 80, "height": 24}}); err != nil {
+ t.Fatalf("resize write: %v", err)
+ }
+ time.Sleep(20 * time.Millisecond)
+ _ = conn.Close()
+
+ sessions.mu.Lock()
+ for _, session := range sessions.m {
+ session.mu.Lock()
+ session.replay = []byte("abc\x1b[?1;10;0cdef")
+ session.mu.Unlock()
+ }
+ sessions.mu.Unlock()
+
+ conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("second dial error = %v", err)
+ }
+ defer conn2.Close()
+ _ = conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
+ msgType, replay, err := conn2.ReadMessage()
+ if err != nil {
+ t.Fatalf("read replay: %v", err)
+ }
+ if msgType != websocket.BinaryMessage {
+ t.Fatalf("expected binary replay message, got %d", msgType)
+ }
+ if string(replay) != "abcdef" {
+ t.Fatalf("unexpected replay payload: %q", string(replay))
+ }
+}
+
+func TestScreenshotAndETag(t *testing.T) {
+ server, httpServer, _ := newServerForTests(t, false)
+ if _, err := server.sessionManager.NewSession("shell", "sid", "shell", 80, 24); err != nil {
+ t.Fatalf("NewSession error = %v", err)
+ }
+ resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell")
+ if err != nil {
+ t.Fatalf("screenshot request error = %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+ etag := resp.Header.Get("ETag")
+ body, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if etag == "" || !strings.Contains(string(body), "`)
+ return b.String()
+}
+
+func colorToHex(color string, isFG bool, palette map[string]string, defaultFG, defaultBG string) string {
+ if color == "" || strings.EqualFold(color, "default") {
+ if isFG {
+ return defaultFG
+ }
+ return defaultBG
+ }
+ if strings.HasPrefix(color, "#") {
+ return color
+ }
+ if len(color) == 6 && isHex(color) {
+ return "#" + color
+ }
+ key := strings.ToLower(color)
+ if value, ok := palette[key]; ok {
+ return value
+ }
+ if value, ok := ansiColors[key]; ok {
+ return value
+ }
+ if isFG {
+ return defaultFG
+ }
+ return defaultBG
+}
+
+func isHex(value string) bool {
+ for _, ch := range value {
+ switch {
+ case ch >= '0' && ch <= '9':
+ case ch >= 'a' && ch <= 'f':
+ case ch >= 'A' && ch <= 'F':
+ default:
+ return false
+ }
+ }
+ return true
+}
diff --git a/go/webterm/svg_exporter_test.go b/go/webterm/svg_exporter_test.go
new file mode 100644
index 0000000..b326fc5
--- /dev/null
+++ b/go/webterm/svg_exporter_test.go
@@ -0,0 +1,23 @@
+package webterm
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/rcarmo/webterm-go-port/terminalstate"
+)
+
+func TestRenderTerminalSVG(t *testing.T) {
+ buffer := [][]terminalstate.Cell{
+ {
+ {Data: "A", FG: "red", BG: "default", Bold: true},
+ },
+ }
+ svg := RenderTerminalSVG(buffer, 1, 1, "webterm", "#000000", "#ffffff", ThemePalettes["xterm"])
+ if !strings.Contains(svg, "