package main import ( "bytes" "encoding/json" "fmt" "io" "math/rand" "net/http" "os" "os/exec" "sync" "time" _ "embed" "atlas9.dev/c/core/tracelog" ) //go:embed devui.html var devuiHTML []byte const ( maxDevTraces = 200 maxDevLogs = 500 ) type devServer struct { mu sync.Mutex pending map[string][]tracelog.Entry traces []devTrace logs []devLog queues []queueSnapshot inbox []devMessage smsInbox []devSMS webhooks []devWebhook behavior mailBehavior clients map[chan devEvent]struct{} } type devEvent struct { Type string Data []byte } type devTrace struct { ID string Time time.Time Method string Path string Status string Duration string Complete bool Entries []devEntry } type devEntry struct { Offset string Level string Msg string Attrs []tracelog.Attr } type devLog struct { Time time.Time Level string Msg string Attrs []tracelog.Attr } type queueSnapshot struct { Name string Pending int Processing int Completed int Failed int Tasks []taskRow } type taskRow struct { TaskID string Queue string Status string Attempts int Created time.Time RunAfter time.Time Payload string } type mailBehavior struct { Latency string // time.ParseDuration format, e.g. "0s", "500ms", "2s" Mode string // "ok", "retry", "random" FailRate float64 // fraction 0–1, used when Mode == "random" } type devMessage struct { Time time.Time To string Subject string Text string HTML string } // devSMS is one text message received by the mock SMS endpoint, so the dev UI // can show the codes the app sent (mirroring the mail inbox). type devSMS struct { Time time.Time To string Text string } // devWebhook is one webhook delivery received by the catchall endpoint, so the // dev UI can show what the app sent (mirroring the mail inbox). type devWebhook struct { Time time.Time Path string Event string Signature string Body string } func newDevServer() *devServer { return &devServer{ pending: map[string][]tracelog.Entry{}, clients: map[chan devEvent]struct{}{}, behavior: mailBehavior{Mode: "ok"}, } } func (s *devServer) Start(addr string) error { mux := http.NewServeMux() mux.HandleFunc("GET /", s.serveIndex) mux.HandleFunc("GET /events", s.serveSSE) mux.HandleFunc("GET /api/openapi.json", s.serveOpenAPI) mux.HandleFunc("POST /api/request", s.serveProxy) mux.HandleFunc("POST /api/seed", s.serveSeed) mux.HandleFunc("POST /mail/send", s.serveMockMail) mux.HandleFunc("GET /api/mail/behavior", s.serveGetBehavior) mux.HandleFunc("POST /api/mail/behavior", s.serveSetBehavior) // Mock SMS receiver: the app's mock SMS transport posts here and messages // (MFA codes) land in the SMS panel, mirroring the mail inbox. mux.HandleFunc("POST /sms/send", s.serveMockSMS) // Catchall webhook receiver: register endpoints at http:///webhook/... // in dev and deliveries land in the Webhooks panel. mux.HandleFunc("POST /webhook/", s.serveMockWebhook) // Catchall Slack receiver: point a Slack endpoint's URL at // http:///slack/... in dev and its messages land in the same panel. mux.HandleFunc("POST /slack/", s.serveMockSlack) return http.ListenAndServe(addr, mux) } func (s *devServer) Add(id string, e tracelog.Entry) { s.mu.Lock() defer s.mu.Unlock() if id == "" { l := devLog{Time: e.Time, Level: e.Level, Msg: e.Msg, Attrs: e.Attrs} s.logs = capAppend(s.logs, l, maxDevLogs) s.broadcast("log", l) return } s.pending[id] = append(s.pending[id], e) if e.Msg == "http response" { t := buildDevTrace(id, s.pending[id], true) delete(s.pending, id) s.traces = capAppend(s.traces, t, maxDevTraces) s.broadcast("trace", t) } } func (s *devServer) AddMetrics(line []byte) { var raw map[string]json.RawMessage if json.Unmarshal(line, &raw) != nil { return } queuesJSON, ok := raw["queues"] if !ok { return } var queues []queueSnapshot if json.Unmarshal(queuesJSON, &queues) != nil { return } s.mu.Lock() defer s.mu.Unlock() s.queues = queues s.broadcast("metrics", struct{ Queues []queueSnapshot }{queues}) } func (s *devServer) Sweep(now time.Time) { s.mu.Lock() defer s.mu.Unlock() for id, entries := range s.pending { if len(entries) > 0 && now.Sub(entries[0].Time) > 30*time.Second { t := buildDevTrace(id, entries, false) delete(s.pending, id) s.traces = capAppend(s.traces, t, maxDevTraces) s.broadcast("trace", t) } } } func (s *devServer) broadcast(typ string, v any) { data, err := json.Marshal(v) if err != nil { return } evt := devEvent{Type: typ, Data: data} for ch := range s.clients { select { case ch <- evt: default: } } } func (s *devServer) serveIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(devuiHTML) } type initPayload struct { Traces []devTrace Logs []devLog Queues []queueSnapshot Messages []devMessage SMS []devSMS Webhooks []devWebhook Behavior mailBehavior } func (s *devServer) serveSSE(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") ch := make(chan devEvent, 64) s.mu.Lock() s.clients[ch] = struct{}{} init := initPayload{ Traces: s.traces, Logs: s.logs, Queues: s.queues, Messages: s.inbox, SMS: s.smsInbox, Webhooks: s.webhooks, Behavior: s.behavior, } s.mu.Unlock() defer func() { s.mu.Lock() delete(s.clients, ch) s.mu.Unlock() }() if data, err := json.Marshal(init); err == nil { fmt.Fprintf(w, "event: init\ndata: %s\n\n", data) w.(http.Flusher).Flush() } for { select { case evt := <-ch: fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Type, evt.Data) w.(http.Flusher).Flush() case <-r.Context().Done(): return } } } func (s *devServer) serveOpenAPI(w http.ResponseWriter, r *http.Request) { data, err := os.ReadFile("api/generated/openapi.json") if err != nil { http.Error(w, "openapi.json not found — run tools/ first", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") w.Write(data) } type proxyReq struct { URL string `json:"URL"` Method string `json:"Method"` Headers map[string]string `json:"Headers"` Body json.RawMessage `json:"Body"` } type proxyRes struct { Status int `json:"Status"` Body json.RawMessage `json:"Body"` } func (s *devServer) serveProxy(w http.ResponseWriter, r *http.Request) { var req proxyReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } method := req.Method if method == "" { method = http.MethodPost } var body io.Reader if len(req.Body) > 0 { body = bytes.NewReader(req.Body) } outReq, err := http.NewRequestWithContext(r.Context(), method, req.URL, body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } outReq.Header.Set("Content-Type", "application/json") for k, v := range req.Headers { outReq.Header.Set(k, v) } resp, err := http.DefaultClient.Do(outReq) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(proxyRes{Status: 0, Body: json.RawMessage(`{"error":` + jsonString(err.Error()) + `}`)}) return } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) // Ensure body is valid JSON for the response; wrap plain text if not. var raw json.RawMessage if json.Unmarshal(respBody, &raw) != nil { raw = json.RawMessage(jsonString(string(respBody))) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(proxyRes{Status: resp.StatusCode, Body: raw}) } type seedReq struct { Force bool } type seedRes struct { Output string Err string } func (s *devServer) serveSeed(w http.ResponseWriter, r *http.Request) { var req seedReq json.NewDecoder(r.Body).Decode(&req) if _, err := os.Stat("dev_data"); err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(seedRes{Err: "dev_data/ directory not found"}) return } cmd := exec.CommandContext(r.Context(), "go", "run", "./dev_data/") cmd.Env = goEnv() if req.Force { cmd.Env = append(cmd.Env, "ATLAS9_FORCE_SEED=1") } out, err := cmd.CombinedOutput() res := seedRes{Output: string(out)} if err != nil { res.Err = err.Error() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res) } func jsonString(s string) string { b, _ := json.Marshal(s) return string(b) } func buildDevTrace(id string, entries []tracelog.Entry, complete bool) devTrace { t := devTrace{ID: id, Complete: complete} if len(entries) > 0 { t.Time = entries[0].Time } for _, e := range entries { switch e.Msg { case "http request": for _, a := range e.Attrs { switch a.Key { case "method": t.Method = a.Value case "path": t.Path = a.Value } } case "http response": for _, a := range e.Attrs { switch a.Key { case "status": t.Status = a.Value case "duration": t.Duration = a.Value } } } t.Entries = append(t.Entries, devEntry{ Offset: "+" + tracelog.FormatDuration(e.Time.Sub(t.Time)), Level: e.Level, Msg: e.Msg, Attrs: e.Attrs, }) } return t } func (s *devServer) serveMockMail(w http.ResponseWriter, r *http.Request) { var req struct { To string Subject string Text string HTML string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } s.mu.Lock() behavior := s.behavior s.mu.Unlock() if behavior.Latency != "" { d, err := time.ParseDuration(behavior.Latency) if err == nil && d > 0 { select { case <-time.After(d): case <-r.Context().Done(): http.Error(w, "cancelled", http.StatusServiceUnavailable) return } } } fail := false switch behavior.Mode { case "retry": fail = true case "random": fail = rand.Float64() < behavior.FailRate } if fail { http.Error(w, "mock mail failure", http.StatusInternalServerError) return } msg := devMessage{ Time: time.Now(), To: req.To, Subject: req.Subject, Text: req.Text, HTML: req.HTML, } s.mu.Lock() s.inbox = capPrepend(s.inbox, msg, 200) s.broadcast("message", msg) s.mu.Unlock() } // serveMockSMS is the mock SMS receiver: it records every text the app sends // into the SMS inbox and broadcasts it, so the dev UI shows the codes. It always // answers 200 — the app sends SMS inline and treats a non-2xx as a send failure. func (s *devServer) serveMockSMS(w http.ResponseWriter, r *http.Request) { var req struct { To string Text string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } msg := devSMS{ Time: time.Now(), To: req.To, Text: req.Text, } s.mu.Lock() s.smsInbox = capPrepend(s.smsInbox, msg, 200) s.broadcast("sms", msg) s.mu.Unlock() } // serveMockWebhook is the catchall webhook receiver: it records every delivered // request (path, event, signature, body) into the webhooks list and broadcasts // it, so the dev UI shows what the app sent. It always answers 200 so the // delivery worker marks the task complete. func (s *devServer) serveMockWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } hook := devWebhook{ Time: time.Now(), Path: r.URL.Path, Event: r.Header.Get("X-Webhook-Event"), Signature: r.Header.Get("X-Webhook-Signature"), Body: string(body), } s.mu.Lock() s.webhooks = capPrepend(s.webhooks, hook, 200) s.broadcast("webhook", hook) s.mu.Unlock() } // serveMockSlack is the catchall Slack receiver: it records every delivered // message into the same Webhooks panel, tagged as a Slack delivery. Slack // incoming webhooks carry no event or signature headers, so only the path and // body (the {"text": ...} payload) are shown. It always answers 200 so the // delivery worker marks the task complete. func (s *devServer) serveMockSlack(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } hook := devWebhook{ Time: time.Now(), Path: r.URL.Path, Event: "(slack)", Body: string(body), } s.mu.Lock() s.webhooks = capPrepend(s.webhooks, hook, 200) s.broadcast("webhook", hook) s.mu.Unlock() } func (s *devServer) serveGetBehavior(w http.ResponseWriter, r *http.Request) { s.mu.Lock() b := s.behavior s.mu.Unlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(b) } func (s *devServer) serveSetBehavior(w http.ResponseWriter, r *http.Request) { var b mailBehavior if err := json.NewDecoder(r.Body).Decode(&b); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } s.mu.Lock() s.behavior = b s.mu.Unlock() } func capPrepend[T any](s []T, v T, max int) []T { s = append([]T{v}, s...) if len(s) > max { s = s[:max] } return s } func capAppend[T any](s []T, v T, max int) []T { s = append(s, v) if len(s) > max { s = s[len(s)-max:] } return s }