feat: complete Go port with go-te terminal emulator

Full Go implementation under go/ replacing Python pyte with go-te:
- HTTP server with WebSocket protocol, SSE, screenshot SVG rendering
- PTY terminal sessions and Docker exec sessions
- Docker watcher (label-based container discovery + event stream)
- CPU stats collection with sparkline SVG rendering
- Session manager with TwoWayMap routing and replay buffers
- C1 normalization, DA filtering, identity generation, theme palettes

Audit fixes for 9 concurrency/correctness issues:
- HTTP transport leak: shared client pool for Docker socket calls
- WebSocket concurrent writes: all writes routed through send channel
- Closed channel panic: atomic.Bool guard on enqueueWSData
- GetFirstRunningSession: use UnsafeForward under SessionManager lock
- NewSession TOCTOU: re-check routeKey after re-acquiring lock
- waitErr data race: protect with mutex in both session types
- Replay buffer fragmentation: copy to new slice on eviction
- go-te dirty tracking: check screen.Dirty before incrementing counter
- Identity modulo bias: rejection sampling for uniform distribution

All Go tests pass (including -race). Python baseline unchanged (397 tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-14 16:18:23 +00:00
parent 7cb3799c3b
commit fd6c1c4e0d
35 changed files with 4228 additions and 1 deletions
+3 -1
View File
@@ -34,7 +34,7 @@ Coupled with [`agentbox`](https://github.com/rcarmo/agentbox), you can use it to
## Known Issues
- `pyte` (the library used to capture the underlying terminal state for screenshots) does not implement some standard escape sequences, resulting in occasionally mis-rendered screenshots. We monkeypatch pyte at runtime to add missing support (CSI S/T scroll, alternate screen buffers, etc.) — see [docs/pyte-patches.md](docs/pyte-patches.md) for details. I'm waiting for `libghostty-vt` to be finished to port this whole thing to Go (or even plain C) and have full fidelity.
- `pyte` (the library used to capture the underlying terminal state for screenshots) does not implement some standard escape sequences, resulting in occasionally mis-rendered screenshots. We monkeypatch pyte at runtime to add missing support (CSI S/T scroll, alternate screen buffers, etc.) — see [docs/pyte-patches.md](docs/pyte-patches.md) for details.
## Installation
@@ -268,6 +268,7 @@ make bundle-watch
- WebSocket protocol (browser ↔ server) is JSON: `["stdin", data]`, `["resize", {"width": w, "height": h}]`, `["ping", data]`.
- Frontend source is in `src/webterm/static/js/terminal.ts`.
- Screenshots use [pyte](https://github.com/selectel/pyte) for ANSI interpretation and custom SVG rendering. `AltScreen` adds alternate screen buffer support, [CSI S/T scroll handling, and Ink partial clear expansion](docs/pyte-patches.md).
- Go runtime port is in `go/webterm` (entrypoint: `go/cmd/webterm`), using [go-te](https://github.com/rcarmo/go-te) for terminal state and screenshots.
- CPU stats are read directly from Docker socket using asyncio (no additional dependencies).
## Requirements
@@ -285,3 +286,4 @@ MIT License - see [LICENSE](LICENSE) for details.
- [ghostty-web](https://github.com/rcarmo/ghostty-web) - Patched Ghostty terminal for the web (vendored fork with theme support)
- [ghostty-web upstream](https://github.com/coder/ghostty-web) - Original Ghostty terminal for the web
- [pyte](https://github.com/selectel/pyte) - PYTE terminal emulator (used for SVG screenshots)
- [go-te](https://github.com/rcarmo/go-te) - Go VT terminal emulator used for the in-progress port
+15
View File
@@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"github.com/rcarmo/webterm-go-port/webterm"
)
func main() {
if err := webterm.RunCLI(os.Args[1:]); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+18
View File
@@ -0,0 +1,18 @@
module github.com/rcarmo/webterm-go-port
go 1.24.0
require (
github.com/creack/pty v1.1.18
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.5.3
github.com/rcarmo/go-te v0.0.0-20260214100434-edf070e453db
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/text v0.34.0 // indirect
)
+20
View File
@@ -0,0 +1,20 @@
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/rcarmo/go-te v0.0.0-20260214100434-edf070e453db h1:wJrQ4ABeQey49QEDHbHHZISpXoC5OtIeihEF4WA2M14=
github.com/rcarmo/go-te v0.0.0-20260214100434-edf070e453db/go.mod h1:cLsrtroxCubS+OHHwH0riB6xeNESfntaHEeI1jPAedk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+153
View File
@@ -0,0 +1,153 @@
package terminalstate
import (
"fmt"
"strconv"
"strings"
"sync"
"github.com/rcarmo/go-te/pkg/te"
)
var ansi16Names = [...]string{
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"brightblack",
"brightred",
"brightgreen",
"brightyellow",
"brightblue",
"brightmagenta",
"brightcyan",
"brightwhite",
}
type Cell struct {
Data string `json:"data"`
FG string `json:"fg"`
BG string `json:"bg"`
Bold bool `json:"bold"`
Italics bool `json:"italics"`
Underscore bool `json:"underscore"`
Reverse bool `json:"reverse"`
}
type Snapshot struct {
Width int `json:"width"`
Height int `json:"height"`
Buffer [][]Cell `json:"buffer"`
HasChanges bool `json:"has_changes"`
}
type Tracker struct {
mu sync.Mutex
screen *te.DiffScreen
stream *te.ByteStream
changeCounter uint64
lastSnapshotCounter uint64
}
func NewTracker(width, height int) *Tracker {
screen := te.NewDiffScreen(width, height)
return &Tracker{
screen: screen,
stream: te.NewByteStream(screen, false),
}
}
func (t *Tracker) Feed(data []byte) error {
if len(data) == 0 {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
if err := t.stream.Feed(data); err != nil {
return err
}
if len(t.screen.Dirty) > 0 {
t.changeCounter++
// Clear dirty set so subsequent feeds detect new changes
for k := range t.screen.Dirty {
delete(t.screen.Dirty, k)
}
}
return nil
}
func (t *Tracker) Resize(width, height int) {
t.mu.Lock()
defer t.mu.Unlock()
if width == t.screen.Columns && height == t.screen.Lines {
return
}
t.screen.Resize(height, width)
t.changeCounter++
}
func (t *Tracker) Snapshot() Snapshot {
t.mu.Lock()
defer t.mu.Unlock()
snapshot := Snapshot{
Width: t.screen.Columns,
Height: t.screen.Lines,
HasChanges: t.changeCounter > t.lastSnapshotCounter,
Buffer: make([][]Cell, t.screen.Lines),
}
t.lastSnapshotCounter = t.changeCounter
for row := 0; row < t.screen.Lines; row++ {
line := make([]Cell, t.screen.Columns)
for col := 0; col < t.screen.Columns; col++ {
raw := t.screen.Buffer[row][col]
data := raw.Data
if data == "" {
data = " "
}
line[col] = Cell{
Data: data,
FG: colorToString(raw.Attr.Fg),
BG: colorToString(raw.Attr.Bg),
Bold: raw.Attr.Bold,
Italics: raw.Attr.Italics,
Underscore: raw.Attr.Underline,
Reverse: raw.Attr.Reverse,
}
}
snapshot.Buffer[row] = line
}
return snapshot
}
func colorToString(color te.Color) string {
if color.Name != "" {
name := strings.ToLower(strings.TrimPrefix(color.Name, "#"))
if len(name) == 6 {
if _, err := strconv.ParseUint(name, 16, 32); err == nil {
return name
}
}
return name
}
switch color.Mode {
case te.ColorDefault:
return "default"
case te.ColorANSI16:
if int(color.Index) < len(ansi16Names) {
return ansi16Names[color.Index]
}
return "default"
case te.ColorANSI256:
return fmt.Sprintf("%d", color.Index)
case te.ColorTrueColor:
return "default"
default:
return "default"
}
}
+53
View File
@@ -0,0 +1,53 @@
package terminalstate
import "testing"
func TestTrackerSnapshotChangeTracking(t *testing.T) {
tracker := NewTracker(10, 3)
if err := tracker.Feed([]byte("hi")); err != nil {
t.Fatalf("Feed() error = %v", err)
}
snapshot := tracker.Snapshot()
if !snapshot.HasChanges {
t.Fatalf("expected first snapshot to report changes")
}
if got := snapshot.Buffer[0][0].Data; got != "h" {
t.Fatalf("expected first cell to be h, got %q", got)
}
if got := snapshot.Buffer[0][1].Data; got != "i" {
t.Fatalf("expected second cell to be i, got %q", got)
}
again := tracker.Snapshot()
if again.HasChanges {
t.Fatalf("expected second snapshot without new input to report no changes")
}
}
func TestTrackerAnsiStyles(t *testing.T) {
tracker := NewTracker(10, 3)
if err := tracker.Feed([]byte("\x1b[31;1mA\x1b[0m")); err != nil {
t.Fatalf("Feed() error = %v", err)
}
snapshot := tracker.Snapshot()
cell := snapshot.Buffer[0][0]
if !cell.Bold {
t.Fatalf("expected bold attribute to be true")
}
if cell.FG != "red" {
t.Fatalf("expected red foreground, got %q", cell.FG)
}
}
func TestTrackerResize(t *testing.T) {
tracker := NewTracker(10, 3)
tracker.Resize(20, 4)
snapshot := tracker.Snapshot()
if snapshot.Width != 20 || snapshot.Height != 4 {
t.Fatalf("unexpected dimensions: got %dx%d", snapshot.Width, snapshot.Height)
}
if !snapshot.HasChanges {
t.Fatalf("expected resize to mark snapshot as changed")
}
}
+106
View File
@@ -0,0 +1,106 @@
package webterm
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
)
func RunCLI(args []string) error {
fs := flag.NewFlagSet("webterm", flag.ContinueOnError)
fs.SetOutput(os.Stdout)
port := DefaultPort
host := DefaultHost
landingManifest := ""
composeManifest := ""
dockerWatch := false
theme := DefaultTheme
fontFamily := ""
fontSize := DefaultFontSize
showVersion := false
fs.IntVar(&port, "port", DefaultPort, "Port for server.")
fs.IntVar(&port, "p", DefaultPort, "Port for server.")
fs.StringVar(&host, "host", DefaultHost, "Host for server.")
fs.StringVar(&host, "H", DefaultHost, "Host for server.")
fs.StringVar(&landingManifest, "landing-manifest", "", "YAML manifest describing landing page tiles.")
fs.StringVar(&landingManifest, "L", "", "YAML manifest describing landing page tiles.")
fs.StringVar(&composeManifest, "compose-manifest", "", "Docker compose YAML; services with label \"webterm-command\" become landing tiles.")
fs.StringVar(&composeManifest, "C", "", "Docker compose YAML; services with label \"webterm-command\" become landing tiles.")
fs.BoolVar(&dockerWatch, "docker-watch", false, "Watch Docker for containers with labels and add/remove sessions dynamically.")
fs.BoolVar(&dockerWatch, "D", false, "Watch Docker for containers with labels and add/remove sessions dynamically.")
fs.StringVar(&theme, "theme", DefaultTheme, "Terminal color theme.")
fs.StringVar(&theme, "t", DefaultTheme, "Terminal color theme.")
fs.StringVar(&fontFamily, "font-family", "", "Terminal font family (CSS font stack).")
fs.StringVar(&fontFamily, "f", "", "Terminal font family (CSS font stack).")
fs.IntVar(&fontSize, "font-size", DefaultFontSize, "Terminal font size in pixels.")
fs.IntVar(&fontSize, "s", DefaultFontSize, "Terminal font size in pixels.")
fs.BoolVar(&showVersion, "version", false, "Print version and exit.")
fs.BoolVar(&showVersion, "v", false, "Print version and exit.")
if err := fs.Parse(args); err != nil {
return err
}
if showVersion {
_, _ = fmt.Fprintln(os.Stdout, "0.0.0")
return nil
}
command := strings.TrimSpace(strings.Join(fs.Args(), " "))
config := DefaultConfig()
landingApps := []App{}
composeMode := false
composeProject := ""
if landingManifest != "" {
apps, err := LoadLandingYAML(landingManifest)
if err != nil {
return err
}
landingApps = apps
}
if composeManifest != "" {
apps, project, err := LoadComposeManifest(composeManifest)
if err != nil {
return err
}
landingApps = apps
composeMode = true
composeProject = project
}
if composeProject == "" && composeManifest != "" {
composeProject = filepath.Base(filepath.Dir(composeManifest))
}
server := NewLocalServer(config, ServerOptions{
Host: host,
Port: port,
Theme: theme,
FontFamily: fontFamily,
FontSize: fontSize,
LandingApps: landingApps,
ComposeMode: composeMode,
ComposeProject: composeProject,
DockerWatch: dockerWatch,
})
if command != "" {
server.sessionManager.AddApp("Terminal", command, "", true, "")
} else if !dockerWatch && len(landingApps) == 0 {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
server.sessionManager.AddApp("Terminal", shell, "", true, "")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return server.Run(ctx)
}
+9
View File
@@ -0,0 +1,9 @@
package webterm
import "testing"
func TestRunCLIVersion(t *testing.T) {
if err := RunCLI([]string{"--version"}); err != nil {
t.Fatalf("RunCLI(--version) error = %v", err)
}
}
+146
View File
@@ -0,0 +1,146 @@
package webterm
import (
"errors"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type App struct {
Name string `yaml:"name" json:"name"`
Slug string `yaml:"slug" json:"slug"`
Path string `yaml:"path" json:"-"`
Color string `yaml:"color" json:"-"`
Command string `yaml:"command" json:"command"`
Terminal bool `yaml:"terminal" json:"-"`
Theme string `yaml:"theme" json:"-"`
}
type Config struct {
Apps []App
}
func DefaultConfig() Config {
return Config{Apps: []App{}}
}
func LoadLandingYAML(manifestPath string) ([]App, error) {
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, err
}
var entries []map[string]any
if err := yaml.Unmarshal(data, &entries); err != nil {
return nil, err
}
apps := make([]App, 0, len(entries))
for _, entry := range entries {
name, _ := entry["name"].(string)
command, _ := entry["command"].(string)
if name == "" || command == "" {
continue
}
slug := asString(entry["slug"])
if slug == "" {
slug = Slugify(name)
}
path := asString(entry["path"])
if path == "" {
path = "./"
}
terminal := true
if value, ok := entry["terminal"].(bool); ok {
terminal = value
}
apps = append(apps, App{
Name: name,
Slug: slug,
Command: command,
Path: path,
Color: asString(entry["color"]),
Terminal: terminal,
Theme: asString(entry["theme"]),
})
}
return apps, nil
}
func LoadComposeManifest(manifestPath string) ([]App, string, error) {
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, "", err
}
var root map[string]any
if err := yaml.Unmarshal(data, &root); err != nil {
return nil, "", err
}
servicesAny, ok := root["services"]
if !ok {
return []App{}, filepath.Base(filepath.Dir(manifestPath)), nil
}
services, ok := servicesAny.(map[string]any)
if !ok {
return nil, "", errors.New("compose services must be mapping")
}
apps := make([]App, 0, len(services))
for name, serviceAny := range services {
service, ok := serviceAny.(map[string]any)
if !ok {
continue
}
command := extractLabel(service["labels"], "webterm-command")
if command == "" {
continue
}
theme := extractLabel(service["labels"], "webterm-theme")
workingDir := asString(service["working_dir"])
if workingDir == "" {
workingDir = "./"
}
apps = append(apps, App{
Name: name,
Slug: Slugify(name),
Command: command,
Path: workingDir,
Terminal: true,
Theme: theme,
})
}
return apps, filepath.Base(filepath.Dir(manifestPath)), nil
}
func extractLabel(labels any, key string) string {
switch raw := labels.(type) {
case map[string]any:
return asString(raw[key])
case []any:
for _, item := range raw {
text, ok := item.(string)
if !ok {
continue
}
for i := 0; i < len(text); i++ {
if text[i] != '=' {
continue
}
if text[:i] == key {
return text[i+1:]
}
break
}
}
}
return ""
}
func asString(value any) string {
if value == nil {
return ""
}
if s, ok := value.(string); ok {
return os.ExpandEnv(s)
}
return ""
}
+62
View File
@@ -0,0 +1,62 @@
package webterm
import (
"os"
"path/filepath"
"testing"
)
func TestLoadLandingYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "landing.yaml")
content := `
- name: Shell
command: /bin/sh
slug: shell
- name: Missing Command
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
apps, err := LoadLandingYAML(path)
if err != nil {
t.Fatalf("LoadLandingYAML() error = %v", err)
}
if len(apps) != 1 {
t.Fatalf("expected 1 app, got %d", len(apps))
}
if apps[0].Slug != "shell" || apps[0].Command != "/bin/sh" {
t.Fatalf("unexpected app: %+v", apps[0])
}
}
func TestLoadComposeManifestReadsLabels(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "compose.yaml")
content := `
services:
web:
labels:
webterm-command: auto
webterm-theme: monokai
db:
labels:
- webterm-command=docker exec -it db psql
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
apps, project, err := LoadComposeManifest(path)
if err != nil {
t.Fatalf("LoadComposeManifest() error = %v", err)
}
if project != filepath.Base(dir) {
t.Fatalf("unexpected project name: %q", project)
}
if len(apps) != 2 {
t.Fatalf("expected 2 apps, got %d", len(apps))
}
if apps[0].Theme != "monokai" && apps[1].Theme != "monokai" {
t.Fatalf("expected theme to be parsed")
}
}
+38
View File
@@ -0,0 +1,38 @@
package webterm
import (
"os"
"runtime"
"strings"
)
const (
DefaultHost = "0.0.0.0"
DefaultPort = 8080
DefaultTheme = "xterm"
DefaultFontSize = 16
DefaultTerminalWidth = 132
DefaultTerminalHeight = 45
ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
DockerHostEnv = "DOCKER_HOST"
AutoCommandSentinel = "__docker_exec__"
)
var Windows = runtime.GOOS == "windows"
func EnvBool(name string) bool {
v, ok := os.LookupEnv(name)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
+340
View File
@@ -0,0 +1,340 @@
package webterm
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"github.com/rcarmo/webterm-go-port/terminalstate"
)
type DockerExecSpec struct {
Container string
Command []string
User string
}
type readBufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (r *readBufferedConn) Read(p []byte) (int, error) {
return r.reader.Read(p)
}
type DockerExecSession struct {
sessionID string
spec DockerExecSpec
socket string
mu sync.RWMutex
connector SessionConnector
execID string
conn net.Conn
tracker *terminalstate.Tracker
replay *ReplayBuffer
escapeBuffer []byte
utf8Buffer []byte
width int
height int
running bool
started bool
done chan struct{}
doneOnce sync.Once
waitErr error
writeMu sync.Mutex
}
func NewDockerExecSession(sessionID string, spec DockerExecSpec, socketPath string) *DockerExecSession {
if socketPath == "" {
socketPath = DockerSocketPath()
}
return &DockerExecSession{
sessionID: sessionID,
spec: spec,
socket: socketPath,
connector: noopConnector{},
replay: NewReplayBuffer(replayBufferSize),
done: make(chan struct{}),
width: DefaultTerminalWidth,
height: DefaultTerminalHeight,
}
}
func (s *DockerExecSession) Open(width, height int) error {
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
execID, err := s.createExec()
if err != nil {
return err
}
conn, err := s.startExecSocket(execID)
if err != nil {
return err
}
s.mu.Lock()
s.execID = execID
s.conn = conn
s.tracker = terminalstate.NewTracker(width, height)
s.width = width
s.height = height
s.running = true
s.mu.Unlock()
_ = s.resizeExec(width, height)
return nil
}
func (s *DockerExecSession) Start(connector SessionConnector) error {
s.mu.Lock()
if connector != nil {
s.connector = connector
}
if s.started {
s.mu.Unlock()
return nil
}
if s.conn == nil {
s.mu.Unlock()
return errors.New("docker session not open")
}
s.started = true
conn := s.conn
s.mu.Unlock()
go s.readLoop(conn)
return nil
}
func (s *DockerExecSession) readLoop(conn net.Conn) {
buf := make([]byte, 32*1024)
for {
n, err := conn.Read(buf)
if n > 0 {
s.handleOutput(buf[:n])
}
if err != nil {
if !errors.Is(err, io.EOF) {
s.mu.Lock()
s.waitErr = err
s.mu.Unlock()
}
break
}
}
s.mu.Lock()
s.running = false
connector := s.connector
s.mu.Unlock()
connector.OnClose()
s.doneOnce.Do(func() { close(s.done) })
}
func (s *DockerExecSession) handleOutput(data []byte) {
s.mu.Lock()
filtered, escapeBuffer := FilterDASequences(data, s.escapeBuffer)
s.escapeBuffer = escapeBuffer
normalized, utf8Buffer := NormalizeC1Controls(filtered, s.utf8Buffer)
s.utf8Buffer = utf8Buffer
tracker := s.tracker
connector := s.connector
s.mu.Unlock()
if len(normalized) == 0 {
return
}
s.replay.Add(normalized)
if tracker != nil {
_ = tracker.Feed(normalized)
}
connector.OnData(normalized)
}
func (s *DockerExecSession) createExec() (string, error) {
payload := map[string]any{
"AttachStdin": true,
"AttachStdout": true,
"AttachStderr": true,
"Tty": true,
"Cmd": s.spec.Command,
}
if strings.TrimSpace(s.spec.User) != "" {
payload["User"] = s.spec.User
}
path := fmt.Sprintf("/containers/%s/exec", url.PathEscape(s.spec.Container))
status, body, err := unixJSONRequest(s.socket, http.MethodPost, path, payload)
if err != nil {
return "", err
}
if status < 200 || status >= 300 {
return "", fmt.Errorf("docker exec create failed (%d): %s", status, string(body))
}
var resp map[string]any
if err := json.Unmarshal(body, &resp); err != nil {
return "", err
}
id, _ := resp["Id"].(string)
if id == "" {
return "", errors.New("docker exec id missing")
}
return id, nil
}
func (s *DockerExecSession) startExecSocket(execID string) (net.Conn, error) {
conn, err := net.Dial("unix", s.socket)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]any{"Detach": false, "Tty": true})
req, err := http.NewRequest(http.MethodPost, "http://unix/exec/"+url.PathEscape(execID)+"/start", bytes.NewReader(payload))
if err != nil {
_ = conn.Close()
return nil, err
}
req.Header.Set("Host", "localhost")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "tcp")
req.ContentLength = int64(len(payload))
if err := req.Write(conn); err != nil {
_ = conn.Close()
return nil, err
}
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, req)
if err != nil {
_ = conn.Close()
return nil, err
}
if resp.StatusCode != http.StatusSwitchingProtocols && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
_ = conn.Close()
return nil, fmt.Errorf("docker exec start failed (%d): %s", resp.StatusCode, string(body))
}
_ = resp.Body.Close()
return &readBufferedConn{Conn: conn, reader: reader}, nil
}
func (s *DockerExecSession) resizeExec(width, height int) error {
s.mu.RLock()
execID := s.execID
s.mu.RUnlock()
if execID == "" {
return nil
}
path := fmt.Sprintf("/exec/%s/resize?h=%d&w=%d", url.PathEscape(execID), height, width)
status, body, err := unixJSONRequest(s.socket, http.MethodPost, path, nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("docker resize failed (%d): %s", status, string(body))
}
return nil
}
func (s *DockerExecSession) Close() error {
s.mu.Lock()
conn := s.conn
s.conn = nil
s.running = false
s.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
s.doneOnce.Do(func() { close(s.done) })
return nil
}
func (s *DockerExecSession) Wait() error {
<-s.done
s.mu.RLock()
defer s.mu.RUnlock()
return s.waitErr
}
func (s *DockerExecSession) SetTerminalSize(width, height int) error {
if width <= 0 {
width = 1
}
if height <= 0 {
height = 1
}
s.mu.Lock()
s.width = width
s.height = height
if s.tracker != nil {
s.tracker.Resize(width, height)
}
s.mu.Unlock()
return s.resizeExec(width, height)
}
func (s *DockerExecSession) ForceRedraw() error {
s.mu.RLock()
width, height := s.width, s.height
s.mu.RUnlock()
return s.SetTerminalSize(width, height)
}
func (s *DockerExecSession) SendBytes(data []byte) bool {
s.mu.RLock()
conn := s.conn
s.mu.RUnlock()
if conn == nil {
return false
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, err := conn.Write(data)
return err == nil
}
func (s *DockerExecSession) SendMeta(_ map[string]any) bool {
return true
}
func (s *DockerExecSession) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.running
}
func (s *DockerExecSession) GetReplayBuffer() []byte {
return s.replay.Bytes()
}
func (s *DockerExecSession) GetScreenSnapshot() terminalstate.Snapshot {
s.mu.RLock()
tracker := s.tracker
width, height := s.width, s.height
s.mu.RUnlock()
if tracker == nil {
return terminalstate.Snapshot{
Width: width,
Height: height,
Buffer: make([][]terminalstate.Cell, height),
}
}
return tracker.Snapshot()
}
func (s *DockerExecSession) UpdateConnector(connector SessionConnector) {
if connector == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.connector = connector
}
+90
View File
@@ -0,0 +1,90 @@
package webterm
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
)
var (
sharedClientsMu sync.RWMutex
sharedClients = map[string]*http.Client{}
)
const defaultDockerSocket = "/var/run/docker.sock"
func DockerSocketPath() string {
dockerHost := strings.TrimSpace(os.Getenv(DockerHostEnv))
if dockerHost == "" {
return defaultDockerSocket
}
if strings.HasPrefix(dockerHost, "unix://") {
return strings.TrimPrefix(dockerHost, "unix://")
}
if strings.HasPrefix(dockerHost, "/") {
return dockerHost
}
return defaultDockerSocket
}
func newUnixHTTPClient(socketPath string, timeout time.Duration) *http.Client {
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", socketPath)
},
}
return &http.Client{Transport: transport, Timeout: timeout}
}
func sharedUnixClient(socketPath string) *http.Client {
sharedClientsMu.RLock()
client, ok := sharedClients[socketPath]
sharedClientsMu.RUnlock()
if ok {
return client
}
sharedClientsMu.Lock()
defer sharedClientsMu.Unlock()
if client, ok = sharedClients[socketPath]; ok {
return client
}
client = newUnixHTTPClient(socketPath, 15*time.Second)
sharedClients[socketPath] = client
return client
}
func unixJSONRequest(socketPath, method, path string, payload any) (int, []byte, error) {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return 0, nil, err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequest(method, "http://unix"+path, body)
if err != nil {
return 0, nil, err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := sharedUnixClient(socketPath).Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return 0, nil, err
}
return resp.StatusCode, respBody, nil
}
+387
View File
@@ -0,0 +1,387 @@
package webterm
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
StatsHistorySize = 180
PollInterval = 10 * time.Second
)
type DockerStatsCollector struct {
socketPath string
composeProject string
mu sync.RWMutex
cpuHistory map[string][]float64
prevCPU map[string][2]uint64
serviceList []string
running bool
stopCh chan struct{}
doneCh chan struct{}
}
func NewDockerStatsCollector(socketPath, composeProject string) *DockerStatsCollector {
if socketPath == "" {
socketPath = DockerSocketPath()
}
return &DockerStatsCollector{
socketPath: socketPath,
composeProject: composeProject,
cpuHistory: map[string][]float64{},
prevCPU: map[string][2]uint64{},
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
func (d *DockerStatsCollector) Available() bool {
path := d.socketPath
if !filepath.IsAbs(path) {
path = filepath.Clean(path)
}
if _, err := os.Stat(path); err != nil {
return false
}
client := newUnixHTTPClient(d.socketPath, 2*time.Second)
resp, err := client.Get("http://unix/_ping")
if err != nil {
return false
}
_ = resp.Body.Close()
return true
}
func (d *DockerStatsCollector) Start(serviceNames []string) {
d.mu.Lock()
if d.running {
d.mu.Unlock()
return
}
d.serviceList = append([]string{}, serviceNames...)
d.running = true
d.mu.Unlock()
go d.pollLoop()
}
func (d *DockerStatsCollector) Stop() {
d.mu.Lock()
if !d.running {
d.mu.Unlock()
return
}
d.running = false
close(d.stopCh)
d.mu.Unlock()
<-d.doneCh
}
func (d *DockerStatsCollector) AddService(name string) {
d.mu.Lock()
defer d.mu.Unlock()
for _, existing := range d.serviceList {
if existing == name {
return
}
}
d.serviceList = append(d.serviceList, name)
}
func (d *DockerStatsCollector) RemoveService(name string) {
d.mu.Lock()
defer d.mu.Unlock()
filtered := d.serviceList[:0]
for _, item := range d.serviceList {
if item != name {
filtered = append(filtered, item)
}
}
d.serviceList = filtered
delete(d.cpuHistory, name)
delete(d.prevCPU, name)
}
func (d *DockerStatsCollector) GetCPUHistory(name string) []float64 {
d.mu.RLock()
defer d.mu.RUnlock()
history := d.cpuHistory[name]
out := make([]float64, len(history))
copy(out, history)
return out
}
func (d *DockerStatsCollector) pollLoop() {
defer close(d.doneCh)
ticker := time.NewTicker(PollInterval)
defer ticker.Stop()
serviceToContainer := map[string]string{}
refreshCounter := 0
for {
select {
case <-d.stopCh:
return
default:
}
d.mu.RLock()
services := append([]string{}, d.serviceList...)
d.mu.RUnlock()
if refreshCounter%30 == 0 || len(serviceToContainer) != len(services) {
serviceToContainer = d.discoverContainers(services)
}
refreshCounter++
for _, service := range services {
containerID := serviceToContainer[service]
if containerID == "" {
continue
}
d.pollContainer(service, containerID)
}
select {
case <-d.stopCh:
return
case <-ticker.C:
}
}
}
func (d *DockerStatsCollector) discoverContainers(serviceNames []string) map[string]string {
status, body, err := unixJSONRequest(d.socketPath, "GET", "/containers/json", nil)
if err != nil || status != 200 {
return map[string]string{}
}
var containers []map[string]any
if err := json.Unmarshal(body, &containers); err != nil {
return map[string]string{}
}
mapping := map[string]string{}
for _, container := range containers {
labels := toStringMap(container["Labels"])
if d.composeProject != "" && labels["com.docker.compose.project"] != d.composeProject {
continue
}
service := labels["com.docker.compose.service"]
containerID := asString(container["Id"])
if len(containerID) > 12 {
containerID = containerID[:12]
}
for _, target := range serviceNames {
if service == target {
mapping[target] = containerID
break
}
names := toStringSlice(container["Names"])
for _, name := range names {
clean := strings.TrimPrefix(name, "/")
if clean == target || strings.Contains(clean, target) {
mapping[target] = containerID
break
}
}
}
}
return mapping
}
func (d *DockerStatsCollector) pollContainer(serviceName, containerID string) {
path := fmt.Sprintf("/containers/%s/stats?stream=false", containerID)
status, body, err := unixJSONRequest(d.socketPath, "GET", path, nil)
if err != nil || status != 200 {
return
}
var stats map[string]any
if err := json.Unmarshal(body, &stats); err != nil {
return
}
cpuStats := toAnyMap(stats["cpu_stats"])
precpuStats := toAnyMap(stats["precpu_stats"])
value, ok := d.calculateCPUPercent(serviceName, cpuStats, precpuStats)
if !ok {
return
}
d.mu.Lock()
defer d.mu.Unlock()
history := append(d.cpuHistory[serviceName], value)
if len(history) > StatsHistorySize {
history = history[len(history)-StatsHistorySize:]
}
d.cpuHistory[serviceName] = history
}
func (d *DockerStatsCollector) calculateCPUPercent(container string, cpuStats, precpuStats map[string]any) (float64, bool) {
cpuUsage := toAnyMap(cpuStats["cpu_usage"])
precpuUsage := toAnyMap(precpuStats["cpu_usage"])
cpuTotal := toUint(cpuUsage["total_usage"])
preTotal := toUint(precpuUsage["total_usage"])
systemCPU := toUint(cpuStats["system_cpu_usage"])
preSystem := toUint(precpuStats["system_cpu_usage"])
d.mu.Lock()
if preTotal == 0 {
if previous, ok := d.prevCPU[container]; ok {
preTotal = previous[0]
preSystem = previous[1]
}
}
d.prevCPU[container] = [2]uint64{cpuTotal, systemCPU}
d.mu.Unlock()
cpuDelta := int64(cpuTotal) - int64(preTotal)
systemDelta := int64(systemCPU) - int64(preSystem)
if cpuDelta < 0 || systemDelta <= 0 {
return 0, false
}
onlineCPUs := toInt(cpuStats["online_cpus"])
if onlineCPUs <= 0 {
perCPU := toAnySlice(cpuUsage["percpu_usage"])
if len(perCPU) == 0 {
onlineCPUs = 1
} else {
onlineCPUs = len(perCPU)
}
}
percent := (float64(cpuDelta) / float64(systemDelta)) * float64(onlineCPUs) * 100.0
maxValue := float64(onlineCPUs) * 100.0
return math.Min(percent, maxValue), true
}
func RenderSparklineSVG(values []float64, width, height int) string {
if width <= 0 {
width = 100
}
if height <= 0 {
height = 20
}
if len(values) == 0 {
return fmt.Sprintf(`<svg width="%d" height="%d" xmlns="http://www.w3.org/2000/svg"></svg>`, width, height)
}
points := make([]float64, 0, len(values)+1)
points = append(points, 0)
points = append(points, values...)
maxValue := 1.0
for _, value := range points {
if value > maxValue {
maxValue = value
}
}
if maxValue <= 0 {
maxValue = 1
}
xStep := float64(width) / float64(max(1, len(points)-1))
line := strings.Builder{}
fill := strings.Builder{}
for i, value := range points {
x := float64(i) * xStep
y := float64(height) - ((value / maxValue) * float64(height-2)) - 1
if i > 0 {
line.WriteByte(' ')
fill.WriteByte(' ')
}
line.WriteString(fmt.Sprintf("%.1f,%.1f", x, y))
fill.WriteString(fmt.Sprintf("%.1f,%.1f", x, y))
}
fill.WriteString(fmt.Sprintf(" %d,%d 0,%d", width, height, height))
return fmt.Sprintf(`<svg width="%d" height="%d" xmlns="http://www.w3.org/2000/svg"><polygon points="%s" fill="rgba(74, 222, 128, 0.2)" /><polyline points="%s" fill="none" stroke="#4ade80" stroke-width="1.5" /></svg>`, width, height, fill.String(), line.String())
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func toAnyMap(value any) map[string]any {
switch raw := value.(type) {
case map[string]any:
return raw
case map[any]any:
out := map[string]any{}
for key, val := range raw {
if text, ok := key.(string); ok {
out[text] = val
}
}
return out
default:
return map[string]any{}
}
}
func toStringMap(value any) map[string]string {
out := map[string]string{}
for key, val := range toAnyMap(value) {
out[key] = asString(val)
}
return out
}
func toAnySlice(value any) []any {
switch raw := value.(type) {
case []any:
return raw
default:
return nil
}
}
func toStringSlice(value any) []string {
raw := toAnySlice(value)
out := make([]string, 0, len(raw))
for _, item := range raw {
if text, ok := item.(string); ok {
out = append(out, text)
}
}
return out
}
func toUint(value any) uint64 {
switch v := value.(type) {
case uint64:
return v
case int:
if v > 0 {
return uint64(v)
}
case int64:
if v > 0 {
return uint64(v)
}
case float64:
if v > 0 {
return uint64(v)
}
case json.Number:
n, _ := v.Int64()
if n > 0 {
return uint64(n)
}
}
return 0
}
func toInt(value any) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
n, _ := v.Int64()
return int(n)
}
return 0
}
+36
View File
@@ -0,0 +1,36 @@
package webterm
import (
"strings"
"testing"
)
func TestRenderSparklineSVG(t *testing.T) {
svg := RenderSparklineSVG([]float64{10, 20, 30}, 120, 24)
if !strings.Contains(svg, "<polyline") {
t.Fatalf("expected polyline in sparkline svg")
}
if !strings.Contains(svg, `width="120"`) {
t.Fatalf("expected width to be present")
}
}
func TestCalculateCPUPercentUsesPreviousStats(t *testing.T) {
collector := NewDockerStatsCollector("/tmp/does-not-exist.sock", "")
cpuStats := map[string]any{
"cpu_usage": map[string]any{
"total_usage": float64(200),
"percpu_usage": []any{1, 2},
},
"system_cpu_usage": float64(400),
"online_cpus": float64(2),
}
preCPU := map[string]any{
"cpu_usage": map[string]any{"total_usage": float64(100)},
"system_cpu_usage": float64(200),
}
value, ok := collector.calculateCPUPercent("svc", cpuStats, preCPU)
if !ok || value <= 0 {
t.Fatalf("expected cpu percent, got ok=%v value=%v", ok, value)
}
}
+276
View File
@@ -0,0 +1,276 @@
package webterm
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
WebtermLabelName = "webterm-command"
WebtermThemeLabel = "webterm-theme"
)
type DockerWatcher struct {
sessionManager *SessionManager
socketPath string
onContainerAdded func(slug, name, command string)
onContainerRemoved func(slug string)
mu sync.RWMutex
managed map[string]string
running bool
cancel context.CancelFunc
waitDone chan struct{}
httpClient *http.Client
}
func NewDockerWatcher(
sessionManager *SessionManager,
socketPath string,
onAdded func(slug, name, command string),
onRemoved func(slug string),
) *DockerWatcher {
if socketPath == "" {
socketPath = DockerSocketPath()
}
return &DockerWatcher{
sessionManager: sessionManager,
socketPath: socketPath,
onContainerAdded: onAdded,
onContainerRemoved: onRemoved,
managed: map[string]string{},
waitDone: make(chan struct{}),
httpClient: newUnixHTTPClient(socketPath, 0),
}
}
func hasWebtermLabel(labels map[string]string) bool {
_, hasCommand := labels[WebtermLabelName]
_, hasTheme := labels[WebtermThemeLabel]
return hasCommand || hasTheme
}
func isAutoLabel(value string) bool {
if strings.TrimSpace(value) == "" {
return true
}
return strings.EqualFold(strings.TrimSpace(value), "auto")
}
func (w *DockerWatcher) getContainerCommand(container map[string]any) string {
labels := toStringMap(container["Labels"])
value := labels[WebtermLabelName]
if isAutoLabel(value) {
return AutoCommandSentinel
}
return value
}
func (w *DockerWatcher) getContainerTheme(container map[string]any) string {
labels := toStringMap(container["Labels"])
return strings.TrimSpace(labels[WebtermThemeLabel])
}
func (w *DockerWatcher) getContainerName(container map[string]any) string {
names := toStringSlice(container["Names"])
if len(names) > 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
}
+23
View File
@@ -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)
}
}
+36
View File
@@ -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)
}
+97
View File
@@ -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
}
+55
View File
@@ -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))
}
}
+51
View File
@@ -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
}
+13
View File
@@ -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)
}
}
+834
View File
@@ -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(`<!DOCTYPE html><html><head><title>Session Dashboard</title><link rel="manifest" href="/static/manifest.json"><meta name="theme-color" content="#0d1117"><link rel="icon" href="/static/icons/webterm-192.png" sizes="192x192"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;margin:16px;background:#0f172a;color:#e2e8f0}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}.tile{background:#1e293b;border:1px solid #334155;border-radius:8px;overflow:hidden;cursor:pointer}.tile-header{padding:10px 12px;font-weight:bold;border-bottom:1px solid #334155;display:flex;justify-content:space-between}.thumb{width:100%%;height:180px;object-fit:contain;background:#0b1220;display:block}.meta{padding:8px 12px;color:#94a3b8;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty{color:#64748b;text-align:center;padding:40px}</style></head><body><h1>Sessions</h1><div id="subtitle"></div><div class="grid" id="grid"></div><script>let tiles=%s;const composeMode=%s;const dockerWatchMode=%s;let cardsBySlug={};const grid=document.getElementById("grid");const subtitle=document.getElementById("subtitle");function openTile(tile){const url='/?route_key='+encodeURIComponent(tile.slug);const target='webterm-'+tile.slug;let win=window.open(url,target);if(!win){window.location.href=url;return}if(typeof win.focus==='function'){win.focus()}}function makeTile(tile){const card=document.createElement('div');card.className='tile';const header=document.createElement('div');header.className='tile-header';header.innerHTML='<span>'+tile.name+'</span>';if(composeMode){const spark=document.createElement('img');spark.width=80;spark.height=16;spark.alt='CPU';header.appendChild(spark);card.sparkline=spark}const img=document.createElement('img');img.className='thumb';img.alt=tile.name;const meta=document.createElement('div');meta.className='meta';meta.textContent=tile.command||'';const body=document.createElement('div');body.appendChild(img);card.appendChild(header);card.appendChild(body);card.appendChild(meta);card.onclick=()=>openTile(tile);card.img=img;return card}function refreshTile(slug){const card=cardsBySlug[slug];if(card){card.img.src='/screenshot.svg?route_key='+encodeURIComponent(slug)+'&_t='+Date.now()}}function refreshSparklines(){if(!composeMode)return;tiles.forEach(tile=>{const card=cardsBySlug[tile.slug];if(card&&card.sparkline){card.sparkline.src='/cpu-sparkline.svg?container='+encodeURIComponent(tile.slug)+'&width=80&height=16&_t='+Date.now()}})}async function refreshTiles(){try{const resp=await fetch('/tiles');const next=await resp.json();const oldSlugs=tiles.map(t=>t.slug).sort().join(',');const newSlugs=next.map(t=>t.slug).sort().join(',');if(oldSlugs!==newSlugs){tiles=next;render()}}catch(_){}}function render(){grid.innerHTML='';cardsBySlug={};if(!tiles.length){grid.innerHTML='<div class="empty">No containers found. Start containers with the webterm-command label.</div>';subtitle.textContent=dockerWatchMode?'Watching for containers with webterm-command label...':'';return}subtitle.textContent=dockerWatchMode?tiles.length+' container(s) found':'';tiles.forEach(tile=>{const card=makeTile(tile);card.img.src='/screenshot.svg?route_key='+encodeURIComponent(tile.slug);grid.appendChild(card);cardsBySlug[tile.slug]=card});refreshSparklines()}let source=null;function startSSE(){if(source)return;source=new EventSource('/events');source.addEventListener('activity',(e)=>{if(e.data==='__dashboard__'){refreshTiles()}else{refreshTile(e.data)}});source.onerror=()=>{source.close();source=null;setTimeout(startSSE,2000)}}render();if(!document.hidden)startSSE();document.addEventListener('visibilitychange',()=>{if(document.hidden){if(source){source.close();source=null}}else startSSE()});if(composeMode){refreshSparklines();setInterval(refreshSparklines,30000)}</script></body></html>`, 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, "<!DOCTYPE html><html><head><title>Webterm Server</title></head><body><h2>No Apps Available</h2><p>No terminal applications are configured.</p></body></html>")
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, `"`, "&quot;")
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(`<!DOCTYPE html><html><head><title>%s</title><link rel="stylesheet" href="/static/monospace.css"><style>html,body{width:100%%;height:100%%}body{background:%s;margin:0;padding:0;overflow:hidden;font-family:var(--webterm-mono)}.webterm-terminal{width:100%%;height:100%%;display:block;overflow:hidden}</style></head><body><div id="terminal" class="webterm-terminal" %s></div><script type="module" src="/static/js/terminal.js"></script></body></html>`, htmlEscape(app.Name), themeBG, dataAttrs)
w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, page)
}
func htmlEscape(value string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(value)
}
func htmlAttrEscape(value string) string {
return strings.NewReplacer("&", "&amp;", `"`, "&quot;", "<", "&lt;", ">", "&gt;").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
}
}
+216
View File
@@ -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), "<svg") {
t.Fatalf("expected svg body and etag")
}
req, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/screenshot.svg?route_key=shell", nil)
req.Header.Set("If-None-Match", etag)
resp2, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("etag request error = %v", err)
}
_ = resp2.Body.Close()
if resp2.StatusCode != http.StatusNotModified {
t.Fatalf("expected 304, got %d", resp2.StatusCode)
}
}
func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
resp, err := http.Get(httpServer.URL + "/?route_key=shell")
if err != nil {
t.Fatalf("root request error = %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
text := string(body)
if !strings.Contains(text, "/static/js/terminal.js") || !strings.Contains(text, "data-session-websocket-url") {
t.Fatalf("unexpected root page: %q", text)
}
resp2, err := http.Get(httpServer.URL + "/cpu-sparkline.svg")
if err != nil {
t.Fatalf("sparkline request error = %v", err)
}
_ = resp2.Body.Close()
if resp2.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for missing container, got %d", resp2.StatusCode)
}
}
+34
View File
@@ -0,0 +1,34 @@
package webterm
import (
"github.com/rcarmo/webterm-go-port/terminalstate"
)
type SessionConnector interface {
OnData(data []byte)
OnBinary(payload []byte)
OnMeta(meta map[string]any)
OnClose()
}
type Session interface {
Open(width, height int) error
Start(connector SessionConnector) error
Close() error
Wait() error
SetTerminalSize(width, height int) error
SendBytes(data []byte) bool
SendMeta(meta map[string]any) bool
IsRunning() bool
GetReplayBuffer() []byte
GetScreenSnapshot() terminalstate.Snapshot
ForceRedraw() error
UpdateConnector(connector SessionConnector)
}
type noopConnector struct{}
func (noopConnector) OnData([]byte) {}
func (noopConnector) OnBinary([]byte) {}
func (noopConnector) OnMeta(map[string]any) {}
func (noopConnector) OnClose() {}
+233
View File
@@ -0,0 +1,233 @@
package webterm
import (
"fmt"
"os"
"strings"
"sync"
)
type SessionManager struct {
mu sync.RWMutex
apps []App
appsBySlug map[string]App
sessions map[string]Session
routes *TwoWayMap[string, string]
sessionFactory func(app App, sessionID string) Session
}
func NewSessionManager(apps []App) *SessionManager {
m := &SessionManager{
apps: append([]App{}, apps...),
appsBySlug: map[string]App{},
sessions: map[string]Session{},
routes: NewTwoWayMap[string, string](),
}
for _, app := range m.apps {
m.appsBySlug[app.Slug] = app
}
m.sessionFactory = m.defaultSessionFactory
return m
}
func (m *SessionManager) SetSessionFactory(factory func(app App, sessionID string) Session) {
m.mu.Lock()
defer m.mu.Unlock()
m.sessionFactory = factory
}
func (m *SessionManager) defaultSessionFactory(app App, sessionID string) Session {
if app.Command == AutoCommandSentinel {
auto := os.Getenv(DockerAutoCommandEnv)
if strings.TrimSpace(auto) == "" {
auto = "/bin/bash"
}
auto = strings.ReplaceAll(auto, "{container}", app.Name)
spec := DockerExecSpec{
Container: app.Name,
Command: splitCommand(auto),
User: os.Getenv(DockerUsernameEnv),
}
return NewDockerExecSession(sessionID, spec, "")
}
return NewTerminalSession(sessionID, app.Command)
}
func splitCommand(command string) []string {
argv, err := shlexSplit(command)
if err != nil || len(argv) == 0 {
return []string{"/bin/sh", "-lc", command}
}
return argv
}
func shlexSplit(command string) ([]string, error) {
return shlexSplitImpl(command)
}
func (m *SessionManager) AddApp(name, command, slug string, terminal bool, theme string) string {
m.mu.Lock()
defer m.mu.Unlock()
if strings.TrimSpace(slug) == "" {
slug = strings.ToLower(GenerateID(12))
}
app := App{
Name: name,
Slug: slug,
Path: "./",
Command: command,
Terminal: terminal,
Theme: theme,
}
m.apps = append(m.apps, app)
m.appsBySlug[slug] = app
return slug
}
func (m *SessionManager) RemoveApp(slug string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.appsBySlug, slug)
filtered := m.apps[:0]
for _, app := range m.apps {
if app.Slug != slug {
filtered = append(filtered, app)
}
}
m.apps = filtered
}
func (m *SessionManager) Apps() []App {
m.mu.RLock()
defer m.mu.RUnlock()
return append([]App{}, m.apps...)
}
func (m *SessionManager) AppBySlug(slug string) (App, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
app, ok := m.appsBySlug[slug]
return app, ok
}
func (m *SessionManager) GetDefaultApp() (App, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.apps) == 0 {
return App{}, false
}
return m.apps[0], true
}
func (m *SessionManager) NewSession(slug, sessionID, routeKey string, width, height int) (Session, error) {
m.mu.Lock()
app, ok := m.appsBySlug[slug]
if !ok {
m.mu.Unlock()
return nil, fmt.Errorf("app not found")
}
// Check if this routeKey already has an active session
if existingID, exists := m.routes.UnsafeForward()[routeKey]; exists {
if existingSession, alive := m.sessions[existingID]; alive && existingSession.IsRunning() {
m.mu.Unlock()
return existingSession, nil
}
// Stale mapping — clean up
delete(m.sessions, existingID)
m.routes.DeleteKey(routeKey)
}
factory := m.sessionFactory
m.mu.Unlock()
session := factory(app, sessionID)
if session == nil {
return nil, fmt.Errorf("session factory returned nil")
}
if err := session.Open(width, height); err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
// Re-check after re-acquiring lock
if existingID, exists := m.routes.UnsafeForward()[routeKey]; exists {
if existingSession, alive := m.sessions[existingID]; alive && existingSession.IsRunning() {
// Another goroutine won the race; close ours and return theirs
go func() { _ = session.Close() }()
return existingSession, nil
}
delete(m.sessions, existingID)
m.routes.DeleteKey(routeKey)
}
m.sessions[sessionID] = session
_ = m.routes.Set(routeKey, sessionID)
return session, nil
}
func (m *SessionManager) OnSessionEnd(sessionID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions, sessionID)
if route, ok := m.routes.GetKey(sessionID); ok {
m.routes.DeleteKey(route)
}
}
func (m *SessionManager) CloseAll() {
m.mu.RLock()
sessions := make([]Session, 0, len(m.sessions))
for _, session := range m.sessions {
sessions = append(sessions, session)
}
m.mu.RUnlock()
for _, session := range sessions {
_ = session.Close()
_ = session.Wait()
}
}
func (m *SessionManager) CloseSession(sessionID string) {
m.mu.RLock()
session := m.sessions[sessionID]
m.mu.RUnlock()
if session == nil {
return
}
_ = session.Close()
_ = session.Wait()
m.OnSessionEnd(sessionID)
}
func (m *SessionManager) GetSession(sessionID string) Session {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessions[sessionID]
}
func (m *SessionManager) GetSessionByRouteKey(routeKey string) Session {
m.mu.RLock()
defer m.mu.RUnlock()
sessionID, ok := m.routes.Get(routeKey)
if !ok {
return nil
}
return m.sessions[sessionID]
}
func (m *SessionManager) GetSessionIDByRouteKey(routeKey string) (string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.routes.Get(routeKey)
}
func (m *SessionManager) GetFirstRunningSession() (string, Session, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
for routeKey, sessionID := range m.routes.UnsafeForward() {
session := m.sessions[sessionID]
if session != nil && session.IsRunning() {
return routeKey, session, true
}
}
return "", nil, false
}
+36
View File
@@ -0,0 +1,36 @@
package webterm
import (
"testing"
)
func TestSessionManagerRouteMappingAndCleanup(t *testing.T) {
manager := NewSessionManager([]App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}})
var created *fakeSession
manager.SetSessionFactory(func(app App, sessionID string) Session {
created = newFakeSession()
return created
})
session, err := manager.NewSession("shell", "sid-1", "route-1", 80, 24)
if err != nil {
t.Fatalf("NewSession() error = %v", err)
}
if session == nil || created == nil {
t.Fatalf("expected session to be created")
}
if got := manager.GetSessionByRouteKey("route-1"); got == nil {
t.Fatalf("expected session lookup by route key")
}
manager.OnSessionEnd("sid-1")
if got := manager.GetSessionByRouteKey("route-1"); got != nil {
t.Fatalf("expected route mapping to be removed")
}
}
func TestSplitCommandWithFallback(t *testing.T) {
parts := splitCommand(`echo "hello world"`)
if len(parts) != 2 || parts[1] != "hello world" {
t.Fatalf("unexpected split command: %#v", parts)
}
}
+7
View File
@@ -0,0 +1,7 @@
package webterm
import "github.com/google/shlex"
func shlexSplitImpl(command string) ([]string, error) {
return shlex.Split(command)
}
+18
View File
@@ -0,0 +1,18 @@
package webterm
import (
"regexp"
"strings"
)
var (
slugNonWord = regexp.MustCompile(`[^\w\s-]`)
slugSpaces = regexp.MustCompile(`[-\s]+`)
)
func Slugify(value string) string {
v := strings.ToLower(value)
v = slugNonWord.ReplaceAllString(v, "")
v = slugSpaces.ReplaceAllString(v, "-")
return strings.Trim(v, "-_")
}
+150
View File
@@ -0,0 +1,150 @@
package webterm
import (
"fmt"
"html"
"strings"
"github.com/rcarmo/webterm-go-port/terminalstate"
)
var ansiColors = map[string]string{
"black": "#000000",
"red": "#cc0000",
"green": "#4e9a06",
"yellow": "#c4a000",
"blue": "#3465a4",
"magenta": "#75507b",
"cyan": "#06989a",
"white": "#d3d7cf",
"brightblack": "#555753",
"brightred": "#ef2929",
"brightgreen": "#8ae234",
"brightyellow": "#fce94f",
"brightblue": "#729fcf",
"brightmagenta": "#ad7fa8",
"brightcyan": "#34e2e2",
"brightwhite": "#eeeeec",
"gray": "#555753",
"grey": "#555753",
"lightgray": "#d3d7cf",
"lightgrey": "#d3d7cf",
"brown": "#c4a000",
}
func RenderTerminalSVG(
buffer [][]terminalstate.Cell,
width, height int,
title, background, foreground string,
palette map[string]string,
) string {
if background == "" {
background = "#000000"
}
if foreground == "" {
foreground = "#d3d7cf"
}
if title == "" {
title = "webterm"
}
if palette == nil {
palette = ansiColors
}
fontSize := 14.0
charWidth := 8.0
lineHeight := 1.2
cellHeight := fontSize * lineHeight
svgWidth := float64(width)*charWidth + 20
svgHeight := float64(height)*cellHeight + 20
var b strings.Builder
b.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.1f %.1f" class="terminal-svg">`, svgWidth, svgHeight))
b.WriteString("<title>" + html.EscapeString(title) + "</title>")
b.WriteString(`<defs><style>.terminal-bg{fill:` + background + `}.terminal-text{font-family:ui-monospace,"SFMono-Regular","Fira Code",Menlo,Monaco,Consolas,"Liberation Mono","DejaVu Sans Mono","Courier New",monospace;font-size:14px;fill:` + foreground + `;white-space:pre;text-rendering:optimizeLegibility}.bold{font-weight:bold}.italic{font-style:italic}.underline{text-decoration:underline}</style></defs>`)
b.WriteString(fmt.Sprintf(`<rect class="terminal-bg" x="0" y="0" width="%.1f" height="%.1f"/>`, svgWidth, svgHeight))
b.WriteString(`<g class="terminal-text">`)
for rowIdx := 0; rowIdx < len(buffer); rowIdx++ {
row := buffer[rowIdx]
rectY := 10 + float64(rowIdx)*cellHeight
textY := rectY + fontSize
var rowText strings.Builder
for col := 0; col < len(row); col++ {
cell := row[col]
charData := cell.Data
if charData == "" {
continue
}
x := 10 + float64(col)*charWidth
fg := colorToHex(cell.FG, true, palette, foreground, background)
bg := colorToHex(cell.BG, false, palette, foreground, background)
if cell.Reverse {
fg, bg = bg, fg
}
if bg != background {
b.WriteString(fmt.Sprintf(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`, x, rectY, charWidth+0.5, cellHeight+0.5, bg))
}
attrs := []string{fmt.Sprintf(`x="%.1f"`, x)}
if fg != foreground {
attrs = append(attrs, `fill="`+fg+`"`)
}
classes := []string{}
if cell.Bold {
classes = append(classes, "bold")
}
if cell.Italics {
classes = append(classes, "italic")
}
if cell.Underscore {
classes = append(classes, "underline")
}
if len(classes) > 0 {
attrs = append(attrs, `class="`+strings.Join(classes, " ")+`"`)
}
rowText.WriteString(`<tspan ` + strings.Join(attrs, " ") + `>` + html.EscapeString(charData) + `</tspan>`)
}
if rowText.Len() > 0 {
b.WriteString(fmt.Sprintf(`<text y="%.1f">%s</text>`, textY, rowText.String()))
}
}
b.WriteString(`</g></svg>`)
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
}
+23
View File
@@ -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, "<svg") || !strings.Contains(svg, "<tspan") {
t.Fatalf("expected svg output with tspan, got %q", svg)
}
if !strings.Contains(svg, "A") {
t.Fatalf("expected rendered cell data")
}
}
+257
View File
@@ -0,0 +1,257 @@
package webterm
import (
"errors"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"github.com/creack/pty"
"github.com/google/shlex"
"github.com/rcarmo/webterm-go-port/terminalstate"
)
type TerminalSession struct {
sessionID string
command string
mu sync.RWMutex
connector SessionConnector
cmd *exec.Cmd
ptyFile *os.File
tracker *terminalstate.Tracker
replay *ReplayBuffer
escapeBuffer []byte
utf8Buffer []byte
width int
height int
started bool
running bool
done chan struct{}
doneOnce sync.Once
waitErr error
writeMu sync.Mutex
}
func NewTerminalSession(sessionID string, command string) *TerminalSession {
return &TerminalSession{
sessionID: sessionID,
command: command,
connector: noopConnector{},
replay: NewReplayBuffer(replayBufferSize),
done: make(chan struct{}),
width: DefaultTerminalWidth,
height: DefaultTerminalHeight,
}
}
func (s *TerminalSession) Open(width, height int) error {
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return nil
}
command := strings.TrimSpace(s.command)
if command == "" {
command = os.Getenv("SHELL")
}
if command == "" {
command = "/bin/sh"
}
argv, err := shlex.Split(command)
if err != nil {
return err
}
if len(argv) == 0 {
return errors.New("empty command")
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Env = append(os.Environ(), "TERM_PROGRAM=webterm-go", "TERM_PROGRAM_VERSION=0.0.0")
file, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)})
if err != nil {
return err
}
s.cmd = cmd
s.ptyFile = file
s.tracker = terminalstate.NewTracker(width, height)
s.width = width
s.height = height
s.running = true
return nil
}
func (s *TerminalSession) Start(connector SessionConnector) error {
s.mu.Lock()
if connector != nil {
s.connector = connector
}
if s.started {
s.mu.Unlock()
return nil
}
if s.ptyFile == nil {
s.mu.Unlock()
return errors.New("session not open")
}
s.started = true
file := s.ptyFile
s.mu.Unlock()
go s.readLoop(file)
return nil
}
func (s *TerminalSession) readLoop(file *os.File) {
buf := make([]byte, 32*1024)
for {
n, err := file.Read(buf)
if n > 0 {
s.handleOutput(buf[:n])
}
if err != nil {
break
}
}
s.mu.Lock()
if s.cmd != nil {
s.waitErr = s.cmd.Wait()
}
s.running = false
connector := s.connector
s.mu.Unlock()
connector.OnClose()
s.doneOnce.Do(func() { close(s.done) })
}
func (s *TerminalSession) handleOutput(data []byte) {
s.mu.Lock()
filtered, escapeBuffer := FilterDASequences(data, s.escapeBuffer)
s.escapeBuffer = escapeBuffer
normalized, utf8Buffer := NormalizeC1Controls(filtered, s.utf8Buffer)
s.utf8Buffer = utf8Buffer
tracker := s.tracker
connector := s.connector
s.mu.Unlock()
if len(normalized) == 0 {
return
}
s.replay.Add(normalized)
if tracker != nil {
_ = tracker.Feed(normalized)
}
connector.OnData(normalized)
}
func (s *TerminalSession) Close() error {
s.mu.Lock()
file := s.ptyFile
cmd := s.cmd
s.ptyFile = nil
s.running = false
s.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Signal(syscall.SIGHUP)
}
if file != nil {
_ = file.Close()
}
s.doneOnce.Do(func() { close(s.done) })
return nil
}
func (s *TerminalSession) Wait() error {
<-s.done
s.mu.RLock()
defer s.mu.RUnlock()
return s.waitErr
}
func (s *TerminalSession) SetTerminalSize(width, height int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.ptyFile == nil {
return errors.New("session closed")
}
if width <= 0 {
width = 1
}
if height <= 0 {
height = 1
}
if err := pty.Setsize(s.ptyFile, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)}); err != nil {
return err
}
s.width = width
s.height = height
if s.tracker != nil {
s.tracker.Resize(width, height)
}
return nil
}
func (s *TerminalSession) ForceRedraw() error {
s.mu.RLock()
width := s.width
height := s.height
s.mu.RUnlock()
return s.SetTerminalSize(width, height)
}
func (s *TerminalSession) SendBytes(data []byte) bool {
s.mu.RLock()
file := s.ptyFile
s.mu.RUnlock()
if file == nil {
return false
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, err := file.Write(data)
return err == nil
}
func (s *TerminalSession) SendMeta(_ map[string]any) bool {
return true
}
func (s *TerminalSession) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.running
}
func (s *TerminalSession) GetReplayBuffer() []byte {
return s.replay.Bytes()
}
func (s *TerminalSession) GetScreenSnapshot() terminalstate.Snapshot {
s.mu.RLock()
tracker := s.tracker
s.mu.RUnlock()
if tracker == nil {
return terminalstate.Snapshot{
Width: s.width,
Height: s.height,
Buffer: make([][]terminalstate.Cell, s.height),
}
}
return tracker.Snapshot()
}
func (s *TerminalSession) UpdateConnector(connector SessionConnector) {
if connector == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.connector = connector
}
+102
View File
@@ -0,0 +1,102 @@
package webterm
import (
"sync"
"github.com/rcarmo/webterm-go-port/terminalstate"
)
type fakeSession struct {
mu sync.Mutex
running bool
replay []byte
snapshot terminalstate.Snapshot
received [][]byte
width int
height int
connector SessionConnector
}
func newFakeSession() *fakeSession {
return &fakeSession{
snapshot: terminalstate.Snapshot{
Width: 80,
Height: 24,
Buffer: [][]terminalstate.Cell{{{Data: "h", FG: "default", BG: "default"}}},
HasChanges: true,
},
}
}
func (f *fakeSession) Open(width, height int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.running = true
f.width = width
f.height = height
f.snapshot.Width = width
f.snapshot.Height = height
return nil
}
func (f *fakeSession) Start(connector SessionConnector) error {
f.mu.Lock()
defer f.mu.Unlock()
f.connector = connector
return nil
}
func (f *fakeSession) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
f.running = false
return nil
}
func (f *fakeSession) Wait() error { return nil }
func (f *fakeSession) SetTerminalSize(width, height int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.width = width
f.height = height
f.snapshot.Width = width
f.snapshot.Height = height
return nil
}
func (f *fakeSession) SendBytes(data []byte) bool {
f.mu.Lock()
defer f.mu.Unlock()
chunk := append([]byte{}, data...)
f.received = append(f.received, chunk)
return true
}
func (f *fakeSession) SendMeta(_ map[string]any) bool { return true }
func (f *fakeSession) IsRunning() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.running
}
func (f *fakeSession) GetReplayBuffer() []byte {
f.mu.Lock()
defer f.mu.Unlock()
return append([]byte{}, f.replay...)
}
func (f *fakeSession) GetScreenSnapshot() terminalstate.Snapshot {
f.mu.Lock()
defer f.mu.Unlock()
return f.snapshot
}
func (f *fakeSession) ForceRedraw() error { return nil }
func (f *fakeSession) UpdateConnector(connector SessionConnector) {
f.mu.Lock()
defer f.mu.Unlock()
f.connector = connector
}
+219
View File
@@ -0,0 +1,219 @@
package webterm
var ThemeBackgrounds = map[string]string{
"tango": "#000000",
"xterm": "#000000",
"monokai": "#2d2a2e",
"ristretto": "#2d2525",
"dark": "#1e1e1e",
"light": "#ffffff",
"dracula": "#282a36",
"catppuccin": "#1e1e2e",
"nord": "#2e3440",
"gruvbox": "#282828",
"solarized": "#002b36",
"tokyo": "#1a1b26",
}
var ThemePalettes = map[string]map[string]string{
"xterm": {
"background": "#000000",
"foreground": "#e5e5e5",
"black": "#000000",
"red": "#cd0000",
"green": "#00cd00",
"yellow": "#cdcd00",
"blue": "#0000cd",
"magenta": "#cd00cd",
"cyan": "#00cdcd",
"white": "#e5e5e5",
"brightblack": "#4d4d4d",
"brightred": "#ff0000",
"brightgreen": "#00ff00",
"brightyellow": "#ffff00",
"brightblue": "#0000ff",
"brightmagenta": "#ff00ff",
"brightcyan": "#00ffff",
"brightwhite": "#ffffff",
},
"monokai": {
"background": "#2d2a2e",
"foreground": "#fcfcfa",
"black": "#403e41",
"red": "#ff6188",
"green": "#a9dc76",
"yellow": "#ffd866",
"blue": "#fc9867",
"magenta": "#ab9df2",
"cyan": "#78dce8",
"white": "#fcfcfa",
"brightblack": "#727072",
"brightred": "#ff6188",
"brightgreen": "#a9dc76",
"brightyellow": "#ffd866",
"brightblue": "#fc9867",
"brightmagenta": "#ab9df2",
"brightcyan": "#78dce8",
"brightwhite": "#fcfcfa",
},
"dark": {
"background": "#1e1e1e",
"foreground": "#d4d4d4",
"black": "#000000",
"red": "#cd3131",
"green": "#0dbc79",
"yellow": "#e5e510",
"blue": "#2472c8",
"magenta": "#bc3fbc",
"cyan": "#11a8cd",
"white": "#e5e5e5",
"brightblack": "#666666",
"brightred": "#f14c4c",
"brightgreen": "#23d18b",
"brightyellow": "#f5f543",
"brightblue": "#3b8eea",
"brightmagenta": "#d670d6",
"brightcyan": "#29b8db",
"brightwhite": "#ffffff",
},
"light": {
"background": "#ffffff",
"foreground": "#383a42",
"black": "#000000",
"red": "#e45649",
"green": "#50a14f",
"yellow": "#c18401",
"blue": "#4078f2",
"magenta": "#a626a4",
"cyan": "#0184bc",
"white": "#a0a1a7",
"brightblack": "#5c6370",
"brightred": "#e06c75",
"brightgreen": "#98c379",
"brightyellow": "#d19a66",
"brightblue": "#61afef",
"brightmagenta": "#c678dd",
"brightcyan": "#56b6c2",
"brightwhite": "#ffffff",
},
"dracula": {
"background": "#282a36",
"foreground": "#f8f8f2",
"black": "#21222c",
"red": "#ff5555",
"green": "#50fa7b",
"yellow": "#f1fa8c",
"blue": "#bd93f9",
"magenta": "#ff79c6",
"cyan": "#8be9fd",
"white": "#f8f8f2",
"brightblack": "#6272a4",
"brightred": "#ff6e6e",
"brightgreen": "#69ff94",
"brightyellow": "#ffffa5",
"brightblue": "#d6acff",
"brightmagenta": "#ff92df",
"brightcyan": "#a4ffff",
"brightwhite": "#ffffff",
},
"catppuccin": {
"background": "#1e1e2e",
"foreground": "#cdd6f4",
"black": "#45475a",
"red": "#f38ba8",
"green": "#a6e3a1",
"yellow": "#f9e2af",
"blue": "#89b4fa",
"magenta": "#f5c2e7",
"cyan": "#94e2d5",
"white": "#bac2de",
"brightblack": "#585b70",
"brightred": "#f38ba8",
"brightgreen": "#a6e3a1",
"brightyellow": "#f9e2af",
"brightblue": "#89b4fa",
"brightmagenta": "#f5c2e7",
"brightcyan": "#94e2d5",
"brightwhite": "#a6adc8",
},
"nord": {
"background": "#2e3440",
"foreground": "#d8dee9",
"black": "#3b4252",
"red": "#bf616a",
"green": "#a3be8c",
"yellow": "#ebcb8b",
"blue": "#81a1c1",
"magenta": "#b48ead",
"cyan": "#88c0d0",
"white": "#e5e9f0",
"brightblack": "#4c566a",
"brightred": "#bf616a",
"brightgreen": "#a3be8c",
"brightyellow": "#ebcb8b",
"brightblue": "#81a1c1",
"brightmagenta": "#b48ead",
"brightcyan": "#8fbcbb",
"brightwhite": "#eceff4",
},
"gruvbox": {
"background": "#282828",
"foreground": "#ebdbb2",
"black": "#282828",
"red": "#cc241d",
"green": "#98971a",
"yellow": "#d79921",
"blue": "#458588",
"magenta": "#b16286",
"cyan": "#689d6a",
"white": "#a89984",
"brightblack": "#928374",
"brightred": "#fb4934",
"brightgreen": "#b8bb26",
"brightyellow": "#fabd2f",
"brightblue": "#83a598",
"brightmagenta": "#d3869b",
"brightcyan": "#8ec07c",
"brightwhite": "#ebdbb2",
},
"solarized": {
"background": "#002b36",
"foreground": "#839496",
"black": "#073642",
"red": "#dc322f",
"green": "#859900",
"yellow": "#b58900",
"blue": "#268bd2",
"magenta": "#d33682",
"cyan": "#2aa198",
"white": "#eee8d5",
"brightblack": "#586e75",
"brightred": "#cb4b16",
"brightgreen": "#586e75",
"brightyellow": "#657b83",
"brightblue": "#839496",
"brightmagenta": "#6c71c4",
"brightcyan": "#93a1a1",
"brightwhite": "#fdf6e3",
},
"tokyo": {
"background": "#1a1b26",
"foreground": "#a9b1d6",
"black": "#15161e",
"red": "#f7768e",
"green": "#9ece6a",
"yellow": "#e0af68",
"blue": "#7aa2f7",
"magenta": "#bb9af7",
"cyan": "#7dcfff",
"white": "#a9b1d6",
"brightblack": "#414868",
"brightred": "#f7768e",
"brightgreen": "#9ece6a",
"brightyellow": "#e0af68",
"brightblue": "#7aa2f7",
"brightmagenta": "#bb9af7",
"brightcyan": "#7dcfff",
"brightwhite": "#c0caf5",
},
}
+72
View File
@@ -0,0 +1,72 @@
package webterm
import (
"fmt"
"sync"
)
type TwoWayMap[K comparable, V comparable] struct {
mu sync.RWMutex
forward map[K]V
reverse map[V]K
}
func NewTwoWayMap[K comparable, V comparable]() *TwoWayMap[K, V] {
return &TwoWayMap[K, V]{
forward: map[K]V{},
reverse: map[V]K{},
}
}
func (m *TwoWayMap[K, V]) Set(key K, value V) error {
m.mu.Lock()
defer m.mu.Unlock()
if old, ok := m.forward[key]; ok && old != value {
delete(m.reverse, old)
}
if existingKey, ok := m.reverse[value]; ok && existingKey != key {
return fmt.Errorf("value already mapped")
}
m.forward[key] = value
m.reverse[value] = key
return nil
}
func (m *TwoWayMap[K, V]) DeleteKey(key K) {
m.mu.Lock()
defer m.mu.Unlock()
if value, ok := m.forward[key]; ok {
delete(m.forward, key)
delete(m.reverse, value)
}
}
func (m *TwoWayMap[K, V]) Get(key K) (V, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.forward[key]
return v, ok
}
func (m *TwoWayMap[K, V]) GetKey(value V) (K, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
k, ok := m.reverse[value]
return k, ok
}
func (m *TwoWayMap[K, V]) Keys() []K {
m.mu.RLock()
defer m.mu.RUnlock()
keys := make([]K, 0, len(m.forward))
for key := range m.forward {
keys = append(keys, key)
}
return keys
}
// UnsafeForward returns the forward map directly. Caller must hold external synchronization.
func (m *TwoWayMap[K, V]) UnsafeForward() map[K]V {
return m.forward
}