Files
CrossRelay/cmd/gateway/main.go
T
kate522 f8acf31ebc CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI
- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens,
  management API /api/* (providers CRUD, apply/restore, settings, backups, logs)
- Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming
  state machine keyed by content block index (text, tool_use, parallel calls)
- ~/.claude/settings.json guarded read/write: timestamped backups, managed env
  keys only, one-click restore
- Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout,
  stdin pipe watchdog prevents orphans, kills the child on exit
- React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query
- Tests: Go table-driven + httptest integration (all green), vitest + RTL
2026-10-18 17:19:13 +08:00

117 lines
3.0 KiB
Go

// Command gateway runs the CrossRelay local gateway: it serves the
// Anthropic Messages API for Claude Code and the management API for the
// CrossRelay desktop app.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
"gitea.rutua.cn/kate522/CrossRelay/internal/gateway"
)
// watchParentShutdown exits the gateway when the parent process goes
// away. The Tauri shell spawns the gateway with a piped stdin and sets
// CROSSRELAY_WATCH_PARENT=1; when the app exits, crashes, or is killed,
// the OS closes the pipe and the gateway shuts down instead of lingering
// as an orphan.
func watchParentShutdown(httpSrv *http.Server, statePath string, done chan struct{}) {
if os.Getenv("CROSSRELAY_WATCH_PARENT") != "1" {
return
}
go func() {
buf := make([]byte, 512)
for {
if _, err := os.Stdin.Read(buf); err != nil {
break
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
_ = os.Remove(statePath)
close(done)
}()
}
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("crossrelay: ")
store, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
addr := "127.0.0.1:0"
cfg := store.Config()
if cfg.Gateway.PortMode == config.PortModeFixed && cfg.Gateway.FixedPort > 0 {
addr = fmt.Sprintf("127.0.0.1:%d", cfg.Gateway.FixedPort)
}
ln, err := net.Listen("tcp", addr)
if err != nil {
fmt.Fprintf(os.Stderr, "CROSSRELAY_LISTEN_FAILED %s\n", err)
os.Exit(1)
}
port := ln.Addr().(*net.TCPAddr).Port
srv := gateway.New(store, port)
httpSrv := &http.Server{Handler: srv.Handler()}
statePath := filepath.Join(config.Dir(), "state.json")
if err := writeState(statePath, port); err != nil {
log.Printf("write state file: %v", err)
}
// The desktop app parses this line to learn the actual port.
fmt.Printf("CROSSRELAY_LISTENING 127.0.0.1:%d\n", port)
shutdownDone := make(chan struct{})
watchParentShutdown(httpSrv, statePath, shutdownDone)
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
_ = os.Remove(statePath)
close(shutdownDone)
}()
if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "CROSSRELAY_SERVE_FAILED %s\n", err)
_ = os.Remove(statePath)
os.Exit(1)
}
<-shutdownDone
}
// writeState records the running gateway for diagnostics.
func writeState(path string, port int) error {
state := map[string]any{
"pid": os.Getpid(),
"port": port,
"started_at": time.Now().UTC().Format(time.RFC3339),
"version": gateway.Version,
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}