Finalize Go-only migration, runtime hardening, and CI/container optimization

This commit consolidates the full repository transition to a Go-first codebase and captures the follow-up performance/reliability work completed in the same stream.

Highlights:
- Remove Python implementation and test suites (, , , ) and retire Python-specific docs/instructions.
- Move and standardize static web assets under , updating Bun/TypeScript build paths and server static resolution logic.
- Rewrite developer workflow to Makefile-first Go targets (vet/test/race/coverage/fuzz/build) and align repository guidance/docs accordingly.
- Update Docker and CI/CD for leaner artifacts:
  - switch to Alpine-based multi-stage build with stripped Go binary
  - install only minimal runtime deps (, )
  - tighten Docker build context via
  - ensure workflows build/publish the  target.
- Improve runtime correctness/latency and reduce duplication:
  - explicit WebSocket outbound frame typing (text vs binary) instead of payload-byte heuristics
  - SSE activity fan-out outside global lock and safer subscriber lifecycle
  - shared session output/snapshot helpers to reduce duplicated logic
  - restart-safe channel lifecycle for Docker watcher/stats start-stop-start flows
  - faster screenshot cold-start path (poll-until-ready within timeout vs fixed sleep).
- Add/expand regression coverage for the above lifecycle and helper paths.

Validation run:
- bun run build
Bundled 3 modules in 10ms

  terminal.js  0.68 MB  (entry point) (Bun typecheck + bundle)
- cd go && go vet ./...
cd go && go test ./...
ok  	github.com/rcarmo/webterm-go-port/cmd/webterm	(cached)
ok  	github.com/rcarmo/webterm-go-port/internal/terminalstate	(cached)
ok  	github.com/rcarmo/webterm-go-port/webterm	(cached)
cd go && go test ./webterm -coverprofile=coverage.out && go tool cover -func=coverage.out
ok  	github.com/rcarmo/webterm-go-port/webterm	(cached)	coverage: 81.0% of statements
github.com/rcarmo/webterm-go-port/webterm/cli.go:14:			RunCLI				51.6%
github.com/rcarmo/webterm-go-port/webterm/config.go:25:			DefaultConfig			100.0%
github.com/rcarmo/webterm-go-port/webterm/config.go:29:			LoadLandingYAML			82.6%
github.com/rcarmo/webterm-go-port/webterm/config.go:70:			LoadComposeManifest		76.9%
github.com/rcarmo/webterm-go-port/webterm/config.go:114:		extractLabel			92.3%
github.com/rcarmo/webterm-go-port/webterm/config.go:138:		asString			80.0%
github.com/rcarmo/webterm-go-port/webterm/constants.go:27:		EnvBool				50.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:30:	Read				100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:56:	NewDockerExecSession		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:72:	Open				90.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:99:	Start				85.7%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:119:	readLoop			83.3%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:143:	handleOutput			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:153:	createExec			75.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:183:	startExecSocket			60.7%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:219:	resizeExec			83.3%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:237:	Close				90.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:250:	Wait				100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:257:	SetTerminalSize			81.8%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:274:	ForceRedraw			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:281:	SendBytes			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:294:	SendMeta			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:298:	IsRunning			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:304:	GetReplayBuffer			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:308:	GetScreenSnapshot		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:316:	UpdateConnector			80.0%
github.com/rcarmo/webterm-go-port/webterm/docker_http.go:23:		DockerSocketPath		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_http.go:37:		newUnixHTTPClient		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_http.go:47:		sharedUnixClient		91.7%
github.com/rcarmo/webterm-go-port/webterm/docker_http.go:64:		unixJSONRequest			84.2%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:33:		NewDockerStatsCollector		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:47:		Available			72.7%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:64:		Start				80.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:78:		Stop				75.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:90:		AddService			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:101:		RemoveService			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:115:		GetCPUHistory			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:124:		pollLoop			95.2%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:160:		discoverContainers		65.4%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:198:		pollContainer			77.8%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:223:		calculateCPUPercent		69.2%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:260:		RenderSparklineSVG		89.3%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:299:		max				100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:306:		toAnyMap			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:323:		toStringMap			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:331:		toAnySlice			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:340:		toStringSlice			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:351:		toUint				91.7%
github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:376:		toInt				85.7%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:34:		NewDockerWatcher		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:54:		hasWebtermLabel			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:60:		isAutoLabel			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:67:		getContainerCommand		80.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:76:		getContainerTheme		100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:81:		getContainerName		42.9%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:93:		containerToSlug			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:98:		addContainer			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:119:	removeContainer			100.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:143:	listLabeledContainers		94.1%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:168:	handleEvent			80.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:202:	watchEvents			85.7%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:239:	ScanExisting			60.0%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:249:	Start				83.3%
github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:265:	Stop				81.8%
github.com/rcarmo/webterm-go-port/webterm/identity.go:12:		GenerateID			94.1%
github.com/rcarmo/webterm-go-port/webterm/normalize.go:13:		FilterDASequences		83.3%
github.com/rcarmo/webterm-go-port/webterm/replay.go:14:			NewReplayBuffer			66.7%
github.com/rcarmo/webterm-go-port/webterm/replay.go:21:			Add				100.0%
github.com/rcarmo/webterm-go-port/webterm/replay.go:43:			Bytes				100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:95:			OnData				100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go💯		OnBinary			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:105:		OnMeta				0.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:107:		OnClose				100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:112:		NewLocalServer			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:163:		findStaticPath			62.5%
github.com/rcarmo/webterm-go-port/webterm/server.go:184:		markRouteActivity		100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:207:		enqueueWSFrame			77.8%
github.com/rcarmo/webterm-go-port/webterm/server.go:233:		stopWSClient			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:246:		wsSender			80.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:256:		createTerminalSession		66.7%
github.com/rcarmo/webterm-go-port/webterm/server.go:278:		clampInt			60.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:288:		parseResizePayload		88.9%
github.com/rcarmo/webterm-go-port/webterm/server.go:303:		handleWebSocket			81.1%
github.com/rcarmo/webterm-go-port/webterm/server.go:425:		chooseRouteForScreenshot	50.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:440:		screenshotTTL			66.7%
github.com/rcarmo/webterm-go-port/webterm/server.go:457:		handleScreenshot		55.7%
github.com/rcarmo/webterm-go-port/webterm/server.go:541:		handleCPUSparkline		94.4%
github.com/rcarmo/webterm-go-port/webterm/server.go:566:		handleEvents			76.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:602:		toIntFromQuery			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:609:		dashboardTiles			81.8%
github.com/rcarmo/webterm-go-port/webterm/server.go:631:		handleTiles			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:636:		getWSURL			65.2%
github.com/rcarmo/webterm-go-port/webterm/server.go:671:		handleRoot			56.8%
github.com/rcarmo/webterm-go-port/webterm/server.go:732:		htmlEscape			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:736:		htmlAttrEscape			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:740:		handleHealth			100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:744:		setupDockerFeatures		40.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:791:		shutdown			62.5%
github.com/rcarmo/webterm-go-port/webterm/server.go:814:		Handler				100.0%
github.com/rcarmo/webterm-go-port/webterm/server.go:829:		Run				77.8%
github.com/rcarmo/webterm-go-port/webterm/session.go:31:		OnData				0.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:32:		OnBinary			0.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:33:		OnMeta				0.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:34:		OnClose				0.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:36:		dispatchSessionOutput		100.0%
github.com/rcarmo/webterm-go-port/webterm/session.go:47:		snapshotFromTracker		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:20:	NewSessionManager		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:34:	SetSessionFactory		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:40:	defaultSessionFactory		87.5%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:57:	splitCommand			75.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:65:	shlexSplit			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:69:	AddApp				87.5%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:88:	RemoveApp			87.5%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:101:	Apps				100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:107:	AppBySlug			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:114:	GetDefaultApp			80.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:123:	NewSession			50.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:167:	OnSessionEnd			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:176:	CloseAll			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:189:	CloseSession			87.5%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:201:	GetSession			100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:207:	GetSessionByRouteKey		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:217:	GetSessionIDByRouteKey		100.0%
github.com/rcarmo/webterm-go-port/webterm/session_manager.go:223:	GetFirstRunningSession		85.7%
github.com/rcarmo/webterm-go-port/webterm/shellsplit.go:5:		shlexSplitImpl			100.0%
github.com/rcarmo/webterm-go-port/webterm/slugify.go:13:		Slugify				100.0%
github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:35:		RenderTerminalSVG		92.6%
github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:113:		colorToHex			87.5%
github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:139:		isHex				100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:37:	NewTerminalSession		100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:49:	Open				86.7%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:90:	Start				85.7%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:110:	readLoop			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:132:	handleOutput			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:142:	Close				100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:160:	Wait				100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:167:	SetTerminalSize			80.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:190:	ForceRedraw			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:198:	SendBytes			88.9%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:211:	SendMeta			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:215:	IsRunning			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:221:	GetReplayBuffer			100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:225:	GetScreenSnapshot		100.0%
github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:233:	UpdateConnector			80.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:14:			NewTwoWayMap			100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:21:			Set				88.9%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:36:			DeleteKey			100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:45:			Get				100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:52:			GetKey				100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:59:			Keys				100.0%
github.com/rcarmo/webterm-go-port/webterm/twoway.go:70:			UnsafeForward			100.0%
total:									(statements)			81.0%
- cd go && go test -race ./...
ok  	github.com/rcarmo/webterm-go-port/cmd/webterm	(cached)
ok  	github.com/rcarmo/webterm-go-port/internal/terminalstate	(cached)
ok  	github.com/rcarmo/webterm-go-port/webterm	(cached)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-14 18:03:28 +00:00
parent 065de286fb
commit 3d4dab2359
81 changed files with 447 additions and 13326 deletions
+2 -16
View File
@@ -147,14 +147,7 @@ func (s *DockerExecSession) handleOutput(data []byte) {
tracker := s.tracker
connector := s.connector
s.mu.Unlock()
if len(filtered) == 0 {
return
}
s.replay.Add(filtered)
if tracker != nil {
_ = tracker.Feed(filtered)
}
connector.OnData(filtered)
dispatchSessionOutput(filtered, tracker, s.replay, connector)
}
func (s *DockerExecSession) createExec() (string, error) {
@@ -317,14 +310,7 @@ func (s *DockerExecSession) GetScreenSnapshot() terminalstate.Snapshot {
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()
return snapshotFromTracker(tracker, width, height)
}
func (s *DockerExecSession) UpdateConnector(connector SessionConnector) {
+2
View File
@@ -67,6 +67,8 @@ func (d *DockerStatsCollector) Start(serviceNames []string) {
d.mu.Unlock()
return
}
d.stopCh = make(chan struct{})
d.doneCh = make(chan struct{})
d.serviceList = append([]string{}, serviceNames...)
d.running = true
d.mu.Unlock()
+8
View File
@@ -93,3 +93,11 @@ func TestDockerStatsHelperConversions(t *testing.T) {
t.Fatalf("max mismatch: %d", got)
}
}
func TestDockerStatsCollectorCanRestart(t *testing.T) {
collector := NewDockerStatsCollector("/tmp/does-not-exist.sock", "")
collector.Start([]string{"svc"})
collector.Stop()
collector.Start([]string{"svc"})
collector.Stop()
}
+7 -4
View File
@@ -199,8 +199,8 @@ func (w *DockerWatcher) handleEvent(event map[string]any) {
}
}
func (w *DockerWatcher) watchEvents(ctx context.Context) {
defer close(w.waitDone)
func (w *DockerWatcher) watchEvents(ctx context.Context, waitDone chan struct{}) {
defer close(waitDone)
filters := url.QueryEscape(`{"event":["start","die"],"type":["container"]}`)
requestURL := "http://unix/events?filters=" + filters
for {
@@ -252,12 +252,14 @@ func (w *DockerWatcher) Start() {
w.mu.Unlock()
return
}
waitDone := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
w.cancel = cancel
w.waitDone = waitDone
w.running = true
w.mu.Unlock()
w.ScanExisting()
go w.watchEvents(ctx)
go w.watchEvents(ctx, waitDone)
}
func (w *DockerWatcher) Stop() {
@@ -268,9 +270,10 @@ func (w *DockerWatcher) Stop() {
}
w.running = false
cancel := w.cancel
waitDone := w.waitDone
w.mu.Unlock()
if cancel != nil {
cancel()
}
<-w.waitDone
<-waitDone
}
+8
View File
@@ -21,3 +21,11 @@ func TestDockerWatcherCommandAndThemeParsing(t *testing.T) {
t.Fatalf("unexpected slug: %q", slug)
}
}
func TestDockerWatcherCanRestart(t *testing.T) {
watcher := NewDockerWatcher(NewSessionManager(nil), "/tmp/does-not-exist.sock", nil, nil)
watcher.Start()
watcher.Stop()
watcher.Start()
watcher.Stop()
}
+55 -32
View File
@@ -48,11 +48,16 @@ type screenshotCacheEntry struct {
type wsClient struct {
routeKey string
conn *websocket.Conn
send chan []byte
send chan wsOutbound
done chan struct{}
closed atomic.Bool
}
type wsOutbound struct {
messageType int
payload []byte
}
type LocalServer struct {
host string
port int
@@ -89,12 +94,12 @@ type localClientConnector struct {
func (c *localClientConnector) OnData(data []byte) {
c.server.markRouteActivity(c.routeKey)
c.server.enqueueWSData(c.routeKey, data)
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, data)
}
func (c *localClientConnector) OnBinary(payload []byte) {
c.server.markRouteActivity(c.routeKey)
c.server.enqueueWSData(c.routeKey, payload)
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, payload)
}
func (c *localClientConnector) OnMeta(_ map[string]any) {}
@@ -162,9 +167,11 @@ func findStaticPath() string {
}
}
candidates := []string{
filepath.Join(".", "src", "webterm", "static"),
filepath.Join("..", "src", "webterm", "static"),
filepath.Join("..", "..", "src", "webterm", "static"),
filepath.Join(".", "webterm", "static"),
filepath.Join(".", "go", "webterm", "static"),
filepath.Join("..", "webterm", "static"),
filepath.Join("..", "go", "webterm", "static"),
filepath.Join("..", "..", "webterm", "static"),
}
for _, candidate := range candidates {
if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
@@ -179,28 +186,37 @@ func (s *LocalServer) markRouteActivity(routeKey string) {
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:
}
}
if now.Sub(last) < 250*time.Millisecond {
s.mu.Unlock()
return
}
s.routeLastSSE[routeKey] = now
subscribers := make([]chan string, 0, len(s.sseSubscribers))
for subscriber := range s.sseSubscribers {
subscribers = append(subscribers, subscriber)
}
s.mu.Unlock()
for _, subscriber := range subscribers {
select {
case subscriber <- routeKey:
default:
}
}
}
func (s *LocalServer) enqueueWSData(routeKey string, data []byte) {
func (s *LocalServer) enqueueWSFrame(routeKey string, messageType int, data []byte) {
s.mu.RLock()
client := s.wsClients[routeKey]
s.mu.RUnlock()
if client == nil || client.closed.Load() {
return
}
payload := append([]byte{}, data...)
frame := wsOutbound{
messageType: messageType,
payload: append([]byte{}, data...),
}
select {
case client.send <- payload:
case client.send <- frame:
default:
// Drop oldest, try again
select {
@@ -208,7 +224,7 @@ func (s *LocalServer) enqueueWSData(routeKey string, data []byte) {
default:
}
select {
case client.send <- payload:
case client.send <- frame:
default:
}
}
@@ -229,14 +245,9 @@ func (s *LocalServer) stopWSClient(routeKey string) {
func (s *LocalServer) wsSender(client *wsClient) {
defer close(client.done)
for payload := range client.send {
for outbound := 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 {
if err := client.conn.WriteMessage(outbound.messageType, outbound.payload); err != nil {
return
}
}
@@ -304,7 +315,7 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
client := &wsClient{
routeKey: routeKey,
conn: conn,
send: make(chan []byte, wsSendQueueMax),
send: make(chan wsOutbound, wsSendQueueMax),
done: make(chan struct{}),
}
s.mu.Lock()
@@ -319,8 +330,12 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
if err != nil || client.closed.Load() {
return
}
frame := wsOutbound{
messageType: websocket.TextMessage,
payload: data,
}
select {
case client.send <- data:
case client.send <- frame:
default:
}
}
@@ -333,7 +348,7 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
sessionCreated = true
replay := daResponsePattern.ReplaceAll(session.GetReplayBuffer(), nil)
if len(replay) > 0 {
s.enqueueWSData(routeKey, replay)
s.enqueueWSFrame(routeKey, websocket.BinaryMessage, replay)
}
} else {
s.sessionManager.OnSessionEnd(sessionID)
@@ -445,9 +460,18 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
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
deadline := time.Now().Add(500 * time.Millisecond)
for {
session = s.sessionManager.GetSessionByRouteKey(routeKey)
if session != nil {
ok = true
break
}
if time.Now().After(deadline) {
break
}
time.Sleep(20 * time.Millisecond)
}
}
}
if !ok || session == nil {
@@ -555,7 +579,6 @@ func (s *LocalServer) handleEvents(w http.ResponseWriter, r *http.Request) {
defer func() {
s.mu.Lock()
delete(s.sseSubscribers, channel)
close(channel)
s.mu.Unlock()
}()
+28
View File
@@ -214,3 +214,31 @@ func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
t.Fatalf("expected 400 for missing container, got %d", resp2.StatusCode)
}
}
func TestMarkRouteActivityBroadcastsWithoutBlockingGlobalLock(t *testing.T) {
server := NewLocalServer(Config{}, ServerOptions{})
ready := make(chan string, 1)
full := make(chan string, 1)
full <- "occupied"
server.mu.Lock()
server.sseSubscribers[ready] = struct{}{}
server.sseSubscribers[full] = struct{}{}
server.routeLastSSE["route-a"] = time.Now().Add(-time.Second)
server.mu.Unlock()
start := time.Now()
server.markRouteActivity("route-a")
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("markRouteActivity took too long: %s", elapsed)
}
select {
case got := <-ready:
if got != "route-a" {
t.Fatalf("unexpected broadcast payload: %q", got)
}
default:
t.Fatalf("expected route activity broadcast")
}
}
+25
View File
@@ -32,3 +32,28 @@ func (noopConnector) OnData([]byte) {}
func (noopConnector) OnBinary([]byte) {}
func (noopConnector) OnMeta(map[string]any) {}
func (noopConnector) OnClose() {}
func dispatchSessionOutput(filtered []byte, tracker *terminalstate.Tracker, replay *ReplayBuffer, connector SessionConnector) {
if len(filtered) == 0 {
return
}
replay.Add(filtered)
if tracker != nil {
_ = tracker.Feed(filtered)
}
connector.OnData(filtered)
}
func snapshotFromTracker(tracker *terminalstate.Tracker, width, height int) terminalstate.Snapshot {
if tracker != nil {
return tracker.Snapshot()
}
if height < 0 {
height = 0
}
return terminalstate.Snapshot{
Width: width,
Height: height,
Buffer: make([][]terminalstate.Cell, height),
}
}
+64
View File
@@ -0,0 +1,64 @@
package webterm
import (
"testing"
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
type captureConnector struct {
data [][]byte
}
func (c *captureConnector) OnData(data []byte) {
c.data = append(c.data, append([]byte{}, data...))
}
func (c *captureConnector) OnBinary([]byte) {}
func (c *captureConnector) OnMeta(map[string]any) {}
func (c *captureConnector) OnClose() {}
func TestDispatchSessionOutput(t *testing.T) {
replay := NewReplayBuffer(1024)
connector := &captureConnector{}
tracker := terminalstate.NewTracker(80, 24)
dispatchSessionOutput([]byte("hello\n"), tracker, replay, connector)
if got := string(replay.Bytes()); got != "hello\n" {
t.Fatalf("unexpected replay: %q", got)
}
if len(connector.data) != 1 || string(connector.data[0]) != "hello\n" {
t.Fatalf("unexpected connector data: %+v", connector.data)
}
}
func TestDispatchSessionOutputEmpty(t *testing.T) {
replay := NewReplayBuffer(1024)
connector := &captureConnector{}
dispatchSessionOutput(nil, nil, replay, connector)
dispatchSessionOutput([]byte{}, nil, replay, connector)
if got := string(replay.Bytes()); got != "" {
t.Fatalf("expected empty replay, got %q", got)
}
if len(connector.data) != 0 {
t.Fatalf("expected no connector events")
}
}
func TestSnapshotFromTrackerFallback(t *testing.T) {
snap := snapshotFromTracker(nil, 10, -2)
if snap.Width != 10 || snap.Height != 0 || len(snap.Buffer) != 0 {
t.Fatalf("unexpected fallback snapshot: %+v", snap)
}
}
func TestSnapshotFromTrackerWithTracker(t *testing.T) {
tracker := terminalstate.NewTracker(4, 2)
_ = tracker.Feed([]byte("ab"))
snap := snapshotFromTracker(tracker, 1, 1)
if snap.Width != 4 || snap.Height != 2 {
t.Fatalf("unexpected tracker snapshot dimensions: %+v", snap)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Webterm Dashboard",
"short_name": "webterm",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": [
{
"src": "/static/icons/webterm-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/webterm-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+78
View File
@@ -0,0 +1,78 @@
/* Generic monospace font stack for terminal rendering.
Prefers system monospace fonts, with optional Fira Code / Roboto Mono if available.
We avoid external font fetching (e.g. Google Fonts) to keep local server self-contained.
*/
:root {
--webterm-mono: ui-monospace, "SFMono-Regular", "FiraCode Nerd Font",
"FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, Consolas,
"Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace;
--terminal-min-width: 10px;
--terminal-min-height: 5px;
}
html, body {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
overflow: hidden;
box-sizing: border-box;
font-family: var(--webterm-mono);
}
/* Prevent scrollbar gutter space reservation */
html {
scrollbar-gutter: auto;
overflow: hidden;
}
/* Terminal container - works with ghostty-web canvas renderer */
.webterm-terminal {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-width: var(--terminal-min-width);
min-height: var(--terminal-min-height);
position: relative;
overflow: hidden;
contain: strict; /* Performance optimization */
}
/* ghostty-web renders to a canvas element */
.webterm-terminal canvas {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: block;
width: 100%;
height: 100%;
}
/* Hidden textarea for keyboard input */
.webterm-terminal textarea {
position: absolute;
opacity: 0;
pointer-events: none;
}
/* High DPI display handling */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.webterm-terminal {
/* ghostty-web handles DPI scaling via devicePixelRatio */
}
}
/* Fallback for older browsers */
@supports not (display: flex) {
.webterm-terminal {
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}
+3 -17
View File
@@ -136,15 +136,7 @@ func (s *TerminalSession) handleOutput(data []byte) {
tracker := s.tracker
connector := s.connector
s.mu.Unlock()
if len(filtered) == 0 {
return
}
s.replay.Add(filtered)
if tracker != nil {
_ = tracker.Feed(filtered)
}
connector.OnData(filtered)
dispatchSessionOutput(filtered, tracker, s.replay, connector)
}
func (s *TerminalSession) Close() error {
@@ -233,15 +225,9 @@ func (s *TerminalSession) GetReplayBuffer() []byte {
func (s *TerminalSession) 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: s.width,
Height: s.height,
Buffer: make([][]terminalstate.Cell, s.height),
}
}
return tracker.Snapshot()
return snapshotFromTracker(tracker, width, height)
}
func (s *TerminalSession) UpdateConnector(connector SessionConnector) {