- 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
117 lines
3.0 KiB
Go
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)
|
|
}
|