[DEPRECATED] Go implementation of plcbundle
at main 38 kB view raw
1package server 2 3import ( 4 "context" 5 "fmt" 6 "io" 7 "net/http" 8 "runtime" 9 "strconv" 10 "strings" 11 "time" 12 13 "github.com/goccy/go-json" 14 "tangled.org/atscan.net/plcbundle-go/internal/plcclient" 15 "tangled.org/atscan.net/plcbundle-go/internal/types" 16) 17 18func (s *Server) handleRoot() http.HandlerFunc { 19 return func(w http.ResponseWriter, r *http.Request) { 20 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 21 22 index := s.manager.GetIndex() 23 stats := index.GetStats() 24 bundleCount := stats["bundle_count"].(int) 25 26 baseURL := getBaseURL(r) 27 wsURL := getWSURL(r) 28 29 var sb strings.Builder 30 31 sb.WriteString(` 32 33 ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⡀⠀⠀⠀⠀⠀⠀⢀⠀⠀⡀⠀⢀⠀⢀⡀⣤⡢⣤⡤⡀⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 34⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡄⡄⠐⡀⠈⣀⠀⡠⡠⠀⣢⣆⢌⡾⢙⠺⢽⠾⡋⣻⡷⡫⢵⣭⢦⣴⠦⠀⢠⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 35⠀⠀⠀⠀⠀⠀⠀⠀⢠⣤⣽⣥⡈⠧⣂⢧⢾⠕⠞⠡⠊⠁⣐⠉⠀⠉⢍⠀⠉⠌⡉⠀⠂⠁⠱⠉⠁⢝⠻⠎⣬⢌⡌⣬⣡⣀⣢⣄⡄⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀ 36⠀⠀⠀⠀⠀⠀⠀⢀⢸⣿⣿⢿⣾⣯⣑⢄⡂⠀⠄⠂⠀⠀⢀⠀⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠄⠐⠀⠀⠀⠀⣄⠭⠂⠈⠜⣩⣿⢝⠃⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀ 37⠀⠀⠀⠀⠀⠀⠀⢀⣻⡟⠏⠀⠚⠈⠚⡉⡝⢶⣱⢤⣅⠈⠀⠄⠀⠀⠀⠀⠀⠠⠀⠀⡂⠐⣤⢕⡪⢼⣈⡹⡇⠏⠏⠋⠅⢃⣪⡏⡇⡍⠀⠀⠀⠀⠀⠀⠀⠀⠀ 38⠀⠀⠀⠀⠀⠀⠀⠀⠺⣻⡄⠀⠀⠀⢠⠌⠃⠐⠉⢡⠱⠧⠝⡯⣮⢶⣴⣤⡆⢐⣣⢅⣮⡟⠦⠍⠉⠀⠁⠐⠀⠀⠀⠄⠐⠡⣽⡸⣎⢁⠀⠀⠀⠀⠀⠀⠀⠀⠀ 39⠀⠀⠀⠀⠀⠀⠀⢈⡻⣧⠀⠁⠐⠀⠀⠀⠀⠀⠀⠊⠀⠕⢀⡉⠈⡫⠽⡿⡟⠿⠟⠁⠀⠀⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠬⠥⣋⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 40⠀⠀⠀⠀⠀⠀⠀⡀⣾⡍⠕⡀⠀⠀⠀⠄⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠥⣤⢌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⠄⢀⠀⢝⢞⣫⡆⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀ 41⠀⠀⠀⠀⠀⠀⠀⠀⣽⡶⡄⠐⡀⠀⠀⠀⠀⠀⠀⢀⠀⠄⠀⠀⠀⠄⠁⠇⣷⡆⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡸⢝⣮⠍⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 42⠀⠀⠀⠀⠀⠀⢀⠀⢾⣷⠀⠠⡀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠁⡁⠀⠀⣾⡥⠖⠀⠀⠀⠂⠀⠀⠀⠀⠀⠁⠀⡀⠁⠀⠀⠻⢳⣻⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 43⠀⠀⠀⠀⠀⠀⠀⠀⣞⡙⠨⣀⠠⠄⠀⠂⠀⠀⠀⠈⢀⠀⠀⠀⠀⠀⠤⢚⢢⣟⠀⠀⠀⠀⡐⠀⠀⡀⠀⠀⠀⠀⠁⠈⠌⠊⣯⣮⡏⠡⠂⠀⠀⠀⠀⠀⠀⠀⠀ 44⠀⠀⠀⠀⠀⠀⠀⠀⣻⡟⡄⡡⣄⠀⠠⠀⠀⡅⠀⠐⠀⡀⠀⡀⠀⠄⠈⠃⠳⠪⠤⠀⠀⠀⠀⡀⠀⠂⠀⠀⠀⠁⠈⢠⣠⠒⠻⣻⡧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 45⠀⠀⠀⠀⠀⠀⠀⠀⠪⡎⠠⢌⠑⡀⠂⠀⠄⠠⠀⠠⠀⠁⡀⠠⠠⡀⣀⠜⢏⡅⠀⠀⡀⠁⠀⠀⠁⠁⠐⠄⡀⢀⠂⠀⠄⢑⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 46⠀⠀⠀⠀⠀⠀⠀⠀⠼⣻⠧⣣⣀⠐⠨⠁⠕⢈⢀⢀⡁⠀⠈⠠⢀⠀⠐⠜⣽⡗⡤⠀⠂⠀⠠⠀⢂⠠⠀⠁⠄⠀⠔⠀⠑⣨⣿⢯⠋⡅⠀⠀⠀⠀⠀⠀⠀⠀⠀ 47⠀⠀⠀⠀⠀⠀⠀⠀⡚⣷⣭⠎⢃⡗⠄⡄⢀⠁⠀⠅⢀⢅⡀⠠⠀⢠⡀⡩⠷⢇⠀⡀⠄⡠⠤⠆⣀⡀⠄⠉⣠⠃⠴⠀⠈⢁⣿⡛⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 48⠀⠀⠀⠀⠀⠀⠀⠘⡬⡿⣿⡏⡻⡯⠌⢁⢛⠠⠓⠐⠐⠐⠌⠃⠋⠂⡢⢰⣈⢏⣰⠂⠈⠀⠠⠒⠡⠌⠫⠭⠩⠢⡬⠆⠿⢷⢿⡽⡧⠉⠊⠀⠀⠀⠀⠀⠀⠀⠀ 49⠀⠀⠀⠀⠀⠀⠀⠀⠺⣷⣺⣗⣿⡶⡎⡅⣣⢎⠠⡅⣢⡖⠴⠬⡈⠂⡨⢡⠾⣣⣢⠀⠀⡹⠄⡄⠄⡇⣰⡖⡊⠔⢹⣄⣿⣭⣵⣿⢷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 50⠀⠀⠀⠀⠀⠀⠀⠀⠩⣿⣿⣲⣿⣷⣟⣼⠟⣬⢉⡠⣪⢜⣂⣁⠥⠓⠚⡁⢶⣷⣠⠂⡄⡢⣀⡐⠧⢆⣒⡲⡳⡫⢟⡃⢪⡧⣟⡟⣯⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀ 51⠀⠀⠀⠀⠀⠀⠀⠀⢺⠟⢿⢟⢻⡗⡮⡿⣲⢷⣆⣏⣇⡧⣄⢖⠾⡷⣿⣤⢳⢷⣣⣦⡜⠗⣭⢂⠩⣹⢿⡲⢎⡧⣕⣖⣓⣽⡿⡖⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 52⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠂⠂⠏⠿⢻⣥⡪⢽⣳⣳⣥⡶⣫⣍⢐⣥⣻⣾⡻⣅⢭⡴⢭⣿⠕⣧⡭⣞⣻⣣⣻⢿⠟⠛⠙⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 53⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠋⠫⠯⣍⢻⣿⣿⣷⣕⣵⣹⣽⣿⣷⣇⡏⣿⡿⣍⡝⠵⠯⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 54⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⠠⠁⠋⢣⠓⡍⣫⠹⣿⣿⣷⡿⠯⠺⠁⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 55⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠋⢀⠋⢈⡿⠿⠁⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ 56 57 plcbundle server 58 59*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~* 60| ⚠️ Preview Version – Do Not Use In Production! | 61*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~* 62| This project and plcbundle specification is currently | 63| unstable and under heavy development. Things can break at | 64| any time. Do not use this for production systems. | 65| Please wait for the 1.0 release. | 66|________________________________________________________________| 67 68`) 69 70 sb.WriteString("\nplcbundle server\n\n") 71 sb.WriteString("What is PLC Bundle?\n") 72 sb.WriteString("━━━━━━━━━━━━━━━━━━━━\n") 73 sb.WriteString("plcbundle archives AT Protocol's DID PLC Directory operations into\n") 74 sb.WriteString("immutable, cryptographically-chained bundles of 10,000 operations.\n\n") 75 sb.WriteString("More info: https://tangled.org/@atscan.net/plcbundle\n\n") 76 77 origin := s.manager.GetPLCOrigin() 78 79 if bundleCount > 0 { 80 sb.WriteString("Bundles\n") 81 sb.WriteString("━━━━━━━\n") 82 sb.WriteString(fmt.Sprintf(" Origin: %s\n", origin)) 83 sb.WriteString(fmt.Sprintf(" Bundle count: %d\n", bundleCount)) 84 85 firstBundle := stats["first_bundle"].(int) 86 lastBundle := stats["last_bundle"].(int) 87 totalSize := stats["total_size"].(int64) 88 totalUncompressed := stats["total_uncompressed_size"].(int64) 89 90 sb.WriteString(fmt.Sprintf(" Last bundle: %d (%s)\n", lastBundle, 91 stats["updated_at"].(time.Time).Format("2006-01-02 15:04:05"))) 92 sb.WriteString(fmt.Sprintf(" Range: %06d - %06d\n", firstBundle, lastBundle)) 93 sb.WriteString(fmt.Sprintf(" Total size: %.2f MB\n", float64(totalSize)/(1000*1000))) 94 sb.WriteString(fmt.Sprintf(" Uncompressed: %.2f MB (%.2fx)\n", 95 float64(totalUncompressed)/(1000*1000), 96 float64(totalUncompressed)/float64(totalSize))) 97 98 if gaps, ok := stats["gaps"].(int); ok && gaps > 0 { 99 sb.WriteString(fmt.Sprintf(" ⚠ Gaps: %d missing bundles\n", gaps)) 100 } 101 102 firstMeta, err := index.GetBundle(firstBundle) 103 if err == nil { 104 sb.WriteString(fmt.Sprintf("\n Root: %s\n", firstMeta.Hash)) 105 } 106 107 lastMeta, err := index.GetBundle(lastBundle) 108 if err == nil { 109 sb.WriteString(fmt.Sprintf(" Head: %s\n", lastMeta.Hash)) 110 } 111 } 112 113 if s.config.SyncMode { 114 mempoolStats := s.manager.GetMempoolStats() 115 count := mempoolStats["count"].(int) 116 targetBundle := mempoolStats["target_bundle"].(int) 117 118 sb.WriteString("\nMempool\n") 119 sb.WriteString("━━━━━━━\n") 120 sb.WriteString(fmt.Sprintf(" Target bundle: %d\n", targetBundle)) 121 sb.WriteString(fmt.Sprintf(" Operations: %d / %d\n", count, types.BUNDLE_SIZE)) 122 123 if count > 0 { 124 progress := float64(count) / float64(types.BUNDLE_SIZE) * 100 125 sb.WriteString(fmt.Sprintf(" Progress: %.1f%%\n", progress)) 126 127 barWidth := 50 128 filled := int(float64(barWidth) * float64(count) / float64(types.BUNDLE_SIZE)) 129 if filled > barWidth { 130 filled = barWidth 131 } 132 bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) 133 sb.WriteString(fmt.Sprintf(" [%s]\n", bar)) 134 135 if firstTime, ok := mempoolStats["first_time"].(time.Time); ok { 136 sb.WriteString(fmt.Sprintf(" First op: %s\n", firstTime.Format("2006-01-02 15:04:05"))) 137 } 138 if lastTime, ok := mempoolStats["last_time"].(time.Time); ok { 139 sb.WriteString(fmt.Sprintf(" Last op: %s\n", lastTime.Format("2006-01-02 15:04:05"))) 140 } 141 } else { 142 sb.WriteString(" (empty)\n") 143 } 144 } 145 146 if s.config.EnableResolver { 147 148 sb.WriteString("\nResolver\n") 149 sb.WriteString("━━━━━━━━\n") 150 sb.WriteString(" Status: enabled\n") 151 152 if didStats := s.manager.GetDIDIndexStats(); didStats["exists"].(bool) { 153 indexedDIDs := didStats["indexed_dids"].(int64) 154 mempoolDIDs := didStats["mempool_dids"].(int64) 155 totalDIDs := didStats["total_dids"].(int64) 156 157 if mempoolDIDs > 0 { 158 sb.WriteString(fmt.Sprintf(" Total DIDs: %s (%s indexed + %s mempool)\n", 159 formatNumber(int(totalDIDs)), 160 formatNumber(int(indexedDIDs)), 161 formatNumber(int(mempoolDIDs)))) 162 } else { 163 sb.WriteString(fmt.Sprintf(" Total DIDs: %s\n", formatNumber(int(totalDIDs)))) 164 } 165 } 166 sb.WriteString("\n") 167 } 168 169 sb.WriteString("Server Stats\n") 170 sb.WriteString("━━━━━━━━━━━━\n") 171 sb.WriteString(fmt.Sprintf(" Version: %s\n", s.config.Version)) 172 sb.WriteString(fmt.Sprintf(" Sync mode: %v\n", s.config.SyncMode)) 173 sb.WriteString(fmt.Sprintf(" WebSocket: %v\n", s.config.EnableWebSocket)) 174 sb.WriteString(fmt.Sprintf(" Handle Resolver: %v\n", s.manager.GetHandleResolver().GetBaseURL())) 175 sb.WriteString(fmt.Sprintf(" Uptime: %s\n", time.Since(s.startTime).Round(time.Second))) 176 177 sb.WriteString("\n\nAPI Endpoints\n") 178 sb.WriteString("━━━━━━━━━━━━━\n") 179 sb.WriteString(" GET / This info page\n") 180 sb.WriteString(" GET /index.json Full bundle index\n") 181 sb.WriteString(" GET /bundle/:number Bundle metadata (JSON)\n") 182 sb.WriteString(" GET /data/:number Raw bundle (zstd compressed)\n") 183 sb.WriteString(" GET /jsonl/:number Decompressed JSONL stream\n") 184 sb.WriteString(" GET /op/:pointer Get single operation\n") 185 sb.WriteString(" GET /status Server status\n") 186 sb.WriteString(" GET /mempool Mempool operations (JSONL)\n") 187 188 if s.config.EnableResolver { 189 sb.WriteString("\nDID Resolution\n") 190 sb.WriteString("━━━━━━━━━━━━━━\n") 191 sb.WriteString(" GET /:did DID Document (W3C format)\n") 192 sb.WriteString(" GET /:did/data PLC State (raw format)\n") 193 sb.WriteString(" GET /:did/log/audit Operation history\n") 194 } 195 196 if s.config.EnableWebSocket { 197 sb.WriteString("\nWebSocket Endpoints\n") 198 sb.WriteString("━━━━━━━━━━━━━━━━━━━\n") 199 sb.WriteString(" WS /ws Live stream (new operations only)\n") 200 sb.WriteString(" WS /ws?cursor=0 Stream all from beginning\n") 201 sb.WriteString(" WS /ws?cursor=N Stream from cursor N\n\n") 202 sb.WriteString("Cursor Format:\n") 203 sb.WriteString(" Global record number: (bundleNumber × 10,000) + position\n") 204 sb.WriteString(" Example: 88410345 = bundle 8841, position 345\n") 205 sb.WriteString(" Default: starts from latest (skips all historical data)\n") 206 207 latestCursor := s.manager.GetCurrentCursor() 208 bundledOps := len(index.GetBundles()) * types.BUNDLE_SIZE 209 mempoolOps := latestCursor - bundledOps 210 211 if s.config.SyncMode && mempoolOps > 0 { 212 sb.WriteString(fmt.Sprintf(" Current latest: %d (%d bundled + %d mempool)\n", 213 latestCursor, bundledOps, mempoolOps)) 214 } else { 215 sb.WriteString(fmt.Sprintf(" Current latest: %d (%d bundles)\n", 216 latestCursor, len(index.GetBundles()))) 217 } 218 } 219 220 sb.WriteString("\nExamples\n") 221 sb.WriteString("━━━━━━━━\n") 222 sb.WriteString(fmt.Sprintf(" curl %s/bundle/1\n", baseURL)) 223 sb.WriteString(fmt.Sprintf(" curl %s/data/42 -o 000042.jsonl.zst\n", baseURL)) 224 sb.WriteString(fmt.Sprintf(" curl %s/jsonl/1\n", baseURL)) 225 sb.WriteString(fmt.Sprintf(" curl %s/op/0\n", baseURL)) 226 227 if s.config.EnableWebSocket { 228 sb.WriteString(fmt.Sprintf(" websocat %s/ws\n", wsURL)) 229 sb.WriteString(fmt.Sprintf(" websocat '%s/ws?cursor=0'\n", wsURL)) 230 } 231 232 if s.config.SyncMode { 233 sb.WriteString(fmt.Sprintf(" curl %s/status\n", baseURL)) 234 sb.WriteString(fmt.Sprintf(" curl %s/mempool\n", baseURL)) 235 } 236 237 sb.WriteString("\n────────────────────────────────────────────────────────────────\n") 238 sb.WriteString("https://tangled.org/@atscan.net/plcbundle\n") 239 240 w.Write([]byte(sb.String())) 241 } 242} 243 244func (s *Server) handleIndexJSON() http.HandlerFunc { 245 return func(w http.ResponseWriter, r *http.Request) { 246 index := s.manager.GetIndex() 247 sendJSON(w, 200, index) 248 } 249} 250 251func (s *Server) handleBundle() http.HandlerFunc { 252 return func(w http.ResponseWriter, r *http.Request) { 253 bundleNum, err := strconv.Atoi(r.PathValue("number")) 254 if err != nil { 255 sendJSON(w, 400, map[string]string{"error": "Invalid bundle number"}) 256 return 257 } 258 259 meta, err := s.manager.GetIndex().GetBundle(bundleNum) 260 if err != nil { 261 sendJSON(w, 404, map[string]string{"error": "Bundle not found"}) 262 return 263 } 264 265 sendJSON(w, 200, meta) 266 } 267} 268 269func (s *Server) handleBundleData() http.HandlerFunc { 270 return func(w http.ResponseWriter, r *http.Request) { 271 bundleNum, err := strconv.Atoi(r.PathValue("number")) 272 if err != nil { 273 sendJSON(w, 400, map[string]string{"error": "Invalid bundle number"}) 274 return 275 } 276 277 reader, err := s.manager.StreamBundleRaw(context.Background(), bundleNum) 278 if err != nil { 279 if strings.Contains(err.Error(), "not in index") || strings.Contains(err.Error(), "not found") { 280 sendJSON(w, 404, map[string]string{"error": "Bundle not found"}) 281 } else { 282 sendJSON(w, 500, map[string]string{"error": err.Error()}) 283 } 284 return 285 } 286 defer reader.Close() 287 288 w.Header().Set("Content-Type", "application/zstd") 289 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl.zst", bundleNum)) 290 291 io.Copy(w, reader) 292 } 293} 294 295func (s *Server) handleBundleJSONL() http.HandlerFunc { 296 return func(w http.ResponseWriter, r *http.Request) { 297 bundleNum, err := strconv.Atoi(r.PathValue("number")) 298 if err != nil { 299 sendJSON(w, 400, map[string]string{"error": "Invalid bundle number"}) 300 return 301 } 302 303 reader, err := s.manager.StreamBundleDecompressed(context.Background(), bundleNum) 304 if err != nil { 305 if strings.Contains(err.Error(), "not in index") || strings.Contains(err.Error(), "not found") { 306 sendJSON(w, 404, map[string]string{"error": "Bundle not found"}) 307 } else { 308 sendJSON(w, 500, map[string]string{"error": err.Error()}) 309 } 310 return 311 } 312 defer reader.Close() 313 314 w.Header().Set("Content-Type", "application/x-ndjson") 315 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl", bundleNum)) 316 317 io.Copy(w, reader) 318 } 319} 320 321func (s *Server) handleStatus() http.HandlerFunc { 322 return func(w http.ResponseWriter, r *http.Request) { 323 index := s.manager.GetIndex() 324 indexStats := index.GetStats() 325 326 response := StatusResponse{ 327 Server: ServerStatus{ 328 Version: s.config.Version, 329 UptimeSeconds: int(time.Since(s.startTime).Seconds()), 330 SyncMode: s.config.SyncMode, 331 WebSocketEnabled: s.config.EnableWebSocket, 332 ResolverEnabled: s.config.EnableResolver, 333 Origin: s.manager.GetPLCOrigin(), 334 }, 335 Bundles: BundleStatus{ 336 Count: indexStats["bundle_count"].(int), 337 TotalSize: indexStats["total_size"].(int64), 338 UncompressedSize: indexStats["total_uncompressed_size"].(int64), 339 UpdatedAt: indexStats["updated_at"].(time.Time), 340 }, 341 } 342 343 if resolver := s.manager.GetHandleResolver(); resolver != nil { 344 response.Server.HandleResolver = resolver.GetBaseURL() 345 } 346 347 if s.config.SyncMode && s.config.SyncInterval > 0 { 348 response.Server.SyncIntervalSeconds = int(s.config.SyncInterval.Seconds()) 349 } 350 351 if bundleCount := response.Bundles.Count; bundleCount > 0 { 352 firstBundle := indexStats["first_bundle"].(int) 353 lastBundle := indexStats["last_bundle"].(int) 354 355 response.Bundles.FirstBundle = firstBundle 356 response.Bundles.LastBundle = lastBundle 357 response.Bundles.StartTime = indexStats["start_time"].(time.Time) 358 response.Bundles.EndTime = indexStats["end_time"].(time.Time) 359 360 if firstMeta, err := index.GetBundle(firstBundle); err == nil { 361 response.Bundles.RootHash = firstMeta.Hash 362 } 363 364 if lastMeta, err := index.GetBundle(lastBundle); err == nil { 365 response.Bundles.HeadHash = lastMeta.Hash 366 response.Bundles.HeadAgeSeconds = int(time.Since(lastMeta.EndTime).Seconds()) 367 } 368 369 if gaps, ok := indexStats["gaps"].(int); ok { 370 response.Bundles.Gaps = gaps 371 response.Bundles.HasGaps = gaps > 0 372 if gaps > 0 { 373 response.Bundles.GapNumbers = index.FindGaps() 374 } 375 } 376 377 totalOps := bundleCount * types.BUNDLE_SIZE 378 response.Bundles.TotalOperations = totalOps 379 380 duration := response.Bundles.EndTime.Sub(response.Bundles.StartTime) 381 if duration.Hours() > 0 { 382 response.Bundles.AvgOpsPerHour = int(float64(totalOps) / duration.Hours()) 383 } 384 } 385 386 if s.config.SyncMode { 387 mempoolStats := s.manager.GetMempoolStats() 388 389 if count, ok := mempoolStats["count"].(int); ok { 390 mempool := &MempoolStatus{ 391 Count: count, 392 TargetBundle: mempoolStats["target_bundle"].(int), 393 CanCreateBundle: mempoolStats["can_create_bundle"].(bool), 394 MinTimestamp: mempoolStats["min_timestamp"].(time.Time), 395 Validated: mempoolStats["validated"].(bool), 396 ProgressPercent: float64(count) / float64(types.BUNDLE_SIZE) * 100, 397 BundleSize: types.BUNDLE_SIZE, 398 OperationsNeeded: types.BUNDLE_SIZE - count, 399 } 400 401 if firstTime, ok := mempoolStats["first_time"].(time.Time); ok { 402 mempool.FirstTime = firstTime 403 mempool.TimespanSeconds = int(time.Since(firstTime).Seconds()) 404 } 405 if lastTime, ok := mempoolStats["last_time"].(time.Time); ok { 406 mempool.LastTime = lastTime 407 mempool.LastOpAgeSeconds = int(time.Since(lastTime).Seconds()) 408 } 409 410 if count > 100 && count < types.BUNDLE_SIZE { 411 if !mempool.FirstTime.IsZero() && !mempool.LastTime.IsZero() { 412 timespan := mempool.LastTime.Sub(mempool.FirstTime) 413 if timespan.Seconds() > 0 { 414 opsPerSec := float64(count) / timespan.Seconds() 415 remaining := types.BUNDLE_SIZE - count 416 mempool.EtaNextBundleSeconds = int(float64(remaining) / opsPerSec) 417 } 418 } 419 } 420 421 response.Mempool = mempool 422 } 423 } 424 425 // DID Index stats 426 didStats := s.manager.GetDIDIndexStats() 427 if didStats["enabled"].(bool) { 428 didIndex := &DIDIndexStatus{ 429 Enabled: true, 430 Exists: didStats["exists"].(bool), 431 TotalDIDs: didStats["total_dids"].(int64), 432 IndexedDIDs: didStats["indexed_dids"].(int64), 433 LastBundle: didStats["last_bundle"].(int), 434 ShardCount: didStats["shard_count"].(int), 435 CachedShards: didStats["cached_shards"].(int), 436 CacheLimit: didStats["cache_limit"].(int), 437 UpdatedAt: didStats["updated_at"].(time.Time), 438 } 439 440 // Mempool DIDs 441 if mempoolDIDs, ok := didStats["mempool_dids"].(int64); ok && mempoolDIDs > 0 { 442 didIndex.MempoolDIDs = mempoolDIDs 443 } 444 445 // Version and format 446 if s.manager.GetDIDIndex() != nil { 447 config := s.manager.GetDIDIndex().GetConfig() 448 didIndex.Version = config.Version 449 didIndex.Format = config.Format 450 } 451 452 // Hot shards 453 if cacheOrder, ok := didStats["cache_order"].([]int); ok && len(cacheOrder) > 0 { 454 maxShards := 10 455 if len(cacheOrder) < maxShards { 456 maxShards = len(cacheOrder) 457 } 458 didIndex.HotShards = cacheOrder[:maxShards] 459 } 460 461 // Cache performance 462 if cacheHitRate, ok := didStats["cache_hit_rate"].(float64); ok { 463 didIndex.CacheHitRate = cacheHitRate 464 } 465 if cacheHits, ok := didStats["cache_hits"].(int64); ok { 466 didIndex.CacheHits = cacheHits 467 } 468 if cacheMisses, ok := didStats["cache_misses"].(int64); ok { 469 didIndex.CacheMisses = cacheMisses 470 } 471 if totalLookups, ok := didStats["total_lookups"].(int64); ok { 472 didIndex.TotalLookups = totalLookups 473 } 474 475 // Lookup performance metrics 476 if avgTime, ok := didStats["avg_lookup_time_ms"].(float64); ok { 477 didIndex.AvgLookupTimeMs = avgTime 478 } 479 if recentAvg, ok := didStats["recent_avg_lookup_time_ms"].(float64); ok { 480 didIndex.RecentAvgLookupTimeMs = recentAvg 481 } 482 if minTime, ok := didStats["min_lookup_time_ms"].(float64); ok { 483 didIndex.MinLookupTimeMs = minTime 484 } 485 if maxTime, ok := didStats["max_lookup_time_ms"].(float64); ok { 486 didIndex.MaxLookupTimeMs = maxTime 487 } 488 if p50, ok := didStats["p50_lookup_time_ms"].(float64); ok { 489 didIndex.P50LookupTimeMs = p50 490 } 491 if p95, ok := didStats["p95_lookup_time_ms"].(float64); ok { 492 didIndex.P95LookupTimeMs = p95 493 } 494 if p99, ok := didStats["p99_lookup_time_ms"].(float64); ok { 495 didIndex.P99LookupTimeMs = p99 496 } 497 if sampleSize, ok := didStats["recent_sample_size"].(int); ok { 498 didIndex.RecentSampleSize = sampleSize 499 } 500 501 response.DIDIndex = didIndex 502 } 503 504 // Resolver performance stats 505 if s.config.EnableResolver { 506 resolverStats := s.manager.GetResolverStats() 507 508 if totalRes, ok := resolverStats["total_resolutions"].(int64); ok && totalRes > 0 { 509 resolver := &ResolverStatus{ 510 Enabled: true, 511 TotalResolutions: totalRes, 512 } 513 514 // Handle resolver URL 515 if hr := s.manager.GetHandleResolver(); hr != nil { 516 resolver.HandleResolver = hr.GetBaseURL() 517 } 518 519 // Counts 520 if v, ok := resolverStats["mempool_hits"].(int64); ok { 521 resolver.MempoolHits = v 522 } 523 if v, ok := resolverStats["bundle_hits"].(int64); ok { 524 resolver.BundleHits = v 525 } 526 if v, ok := resolverStats["errors"].(int64); ok { 527 resolver.Errors = v 528 } 529 if v, ok := resolverStats["success_rate"].(float64); ok { 530 resolver.SuccessRate = v 531 } 532 if v, ok := resolverStats["mempool_hit_rate"].(float64); ok { 533 resolver.MempoolHitRate = v 534 } 535 536 // Overall averages 537 if v, ok := resolverStats["avg_total_time_ms"].(float64); ok { 538 resolver.AvgTotalTimeMs = v 539 } 540 if v, ok := resolverStats["avg_mempool_time_ms"].(float64); ok { 541 resolver.AvgMempoolTimeMs = v 542 } 543 if v, ok := resolverStats["avg_index_time_ms"].(float64); ok { 544 resolver.AvgIndexTimeMs = v 545 } 546 if v, ok := resolverStats["avg_load_op_time_ms"].(float64); ok { 547 resolver.AvgLoadOpTimeMs = v 548 } 549 550 // Recent averages 551 if v, ok := resolverStats["recent_avg_total_time_ms"].(float64); ok { 552 resolver.RecentAvgTotalTimeMs = v 553 } 554 if v, ok := resolverStats["recent_avg_mempool_time_ms"].(float64); ok { 555 resolver.RecentAvgMempoolTimeMs = v 556 } 557 if v, ok := resolverStats["recent_avg_index_time_ms"].(float64); ok { 558 resolver.RecentAvgIndexTimeMs = v 559 } 560 if v, ok := resolverStats["recent_avg_load_time_ms"].(float64); ok { 561 resolver.RecentAvgLoadTimeMs = v 562 } 563 if v, ok := resolverStats["recent_sample_size"].(int); ok { 564 resolver.RecentSampleSize = v 565 } 566 567 // Percentiles 568 if v, ok := resolverStats["min_total_time_ms"].(float64); ok { 569 resolver.MinTotalTimeMs = v 570 } 571 if v, ok := resolverStats["max_total_time_ms"].(float64); ok { 572 resolver.MaxTotalTimeMs = v 573 } 574 if v, ok := resolverStats["p50_total_time_ms"].(float64); ok { 575 resolver.P50TotalTimeMs = v 576 } 577 if v, ok := resolverStats["p95_total_time_ms"].(float64); ok { 578 resolver.P95TotalTimeMs = v 579 } 580 if v, ok := resolverStats["p99_total_time_ms"].(float64); ok { 581 resolver.P99TotalTimeMs = v 582 } 583 if v, ok := resolverStats["p95_index_time_ms"].(float64); ok { 584 resolver.P95IndexTimeMs = v 585 } 586 if v, ok := resolverStats["p95_load_op_time_ms"].(float64); ok { 587 resolver.P95LoadOpTimeMs = v 588 } 589 590 response.Resolver = resolver 591 } else { 592 // No resolutions yet, but resolver is enabled 593 response.Resolver = &ResolverStatus{ 594 Enabled: true, 595 TotalResolutions: 0, 596 } 597 598 if hr := s.manager.GetHandleResolver(); hr != nil { 599 response.Resolver.HandleResolver = hr.GetBaseURL() 600 } 601 } 602 } 603 604 sendJSON(w, 200, response) 605 } 606} 607 608func (s *Server) handleMempool() http.HandlerFunc { 609 return func(w http.ResponseWriter, r *http.Request) { 610 ops, err := s.manager.GetMempoolOperations() 611 if err != nil { 612 sendJSON(w, 500, map[string]string{"error": err.Error()}) 613 return 614 } 615 616 w.Header().Set("Content-Type", "application/x-ndjson") 617 618 if len(ops) == 0 { 619 return 620 } 621 622 for _, op := range ops { 623 if len(op.RawJSON) > 0 { 624 w.Write(op.RawJSON) 625 } else { 626 data, _ := json.Marshal(op) 627 w.Write(data) 628 } 629 w.Write([]byte("\n")) 630 } 631 } 632} 633 634func (s *Server) handleDebugMemory() http.HandlerFunc { 635 return func(w http.ResponseWriter, r *http.Request) { 636 var m runtime.MemStats 637 runtime.ReadMemStats(&m) 638 639 didStats := s.manager.GetDIDIndexStats() 640 641 beforeAlloc := m.Alloc / 1024 / 1024 642 643 runtime.GC() 644 runtime.ReadMemStats(&m) 645 afterAlloc := m.Alloc / 1024 / 1024 646 647 response := fmt.Sprintf(`Memory Stats: 648 Alloc: %d MB 649 TotalAlloc: %d MB 650 Sys: %d MB 651 NumGC: %d 652 653DID Index: 654 Cached shards: %d/%d 655 656After GC: 657 Alloc: %d MB 658`, 659 beforeAlloc, 660 m.TotalAlloc/1024/1024, 661 m.Sys/1024/1024, 662 m.NumGC, 663 didStats["cached_shards"], 664 didStats["cache_limit"], 665 afterAlloc) 666 667 w.Header().Set("Content-Type", "text/plain") 668 w.Write([]byte(response)) 669 } 670} 671 672func (s *Server) handleDIDRouting(w http.ResponseWriter, r *http.Request) { 673 path := strings.TrimPrefix(r.URL.Path, "/") 674 675 parts := strings.SplitN(path, "/", 2) 676 input := parts[0] 677 678 // Ignore common browser files before any validation 679 if isCommonBrowserFile(input) { 680 w.WriteHeader(http.StatusNotFound) 681 return 682 } 683 684 // Quick validation: must be either a DID or a valid handle format 685 if !isValidDIDOrHandle(input) { 686 sendJSON(w, 404, map[string]string{"error": "not found"}) 687 return 688 } 689 690 // Route to appropriate handler 691 if len(parts) == 1 { 692 s.handleDIDDocument(input)(w, r) 693 } else if parts[1] == "data" { 694 s.handleDIDData(input)(w, r) 695 } else if parts[1] == "log/audit" { 696 s.handleDIDAuditLog(input)(w, r) 697 } else { 698 sendJSON(w, 404, map[string]string{"error": "not found"}) 699 } 700} 701 702func isCommonBrowserFile(path string) bool { 703 // Common files browsers request automatically 704 commonFiles := []string{ 705 "favicon.ico", 706 "robots.txt", 707 "sitemap.xml", 708 "apple-touch-icon.png", 709 "apple-touch-icon-precomposed.png", 710 ".well-known", 711 } 712 713 for _, file := range commonFiles { 714 if path == file || strings.HasPrefix(path, file) { 715 return true 716 } 717 } 718 719 // Common file extensions that are NOT DIDs/handles 720 commonExtensions := []string{ 721 ".ico", ".png", ".jpg", ".jpeg", ".gif", ".svg", 722 ".css", ".js", ".woff", ".woff2", ".ttf", ".eot", 723 ".xml", ".txt", ".html", ".webmanifest", 724 } 725 726 for _, ext := range commonExtensions { 727 if strings.HasSuffix(path, ext) { 728 return true 729 } 730 } 731 732 return false 733} 734 735// isValidDIDOrHandle does quick format check before expensive resolution 736func isValidDIDOrHandle(input string) bool { 737 // Empty input 738 if input == "" { 739 return false 740 } 741 742 // If it's a DID 743 if strings.HasPrefix(input, "did:") { 744 // Only accept did:plc: method (reject other methods at routing level) 745 if !strings.HasPrefix(input, "did:plc:") { 746 return false // Returns 404 for did:web:, did:key:, did:invalid:, etc 747 } 748 749 // Accept any did:plc:* - let handler validate exact format 750 // This allows invalid formats to reach handler and get proper 400 errors 751 return true 752 } 753 754 // Not a DID - validate as handle 755 // Must have at least one dot (domain.tld) 756 if !strings.Contains(input, ".") { 757 return false 758 } 759 760 // Must not have invalid characters for a domain 761 // Simple check: alphanumeric, dots, hyphens only 762 for _, c := range input { 763 if !((c >= 'a' && c <= 'z') || 764 (c >= 'A' && c <= 'Z') || 765 (c >= '0' && c <= '9') || 766 c == '.' || c == '-') { 767 return false 768 } 769 } 770 771 // Basic length check (DNS max is 253) 772 if len(input) > 253 { 773 return false 774 } 775 776 // Must not start or end with dot or hyphen 777 if strings.HasPrefix(input, ".") || strings.HasSuffix(input, ".") || 778 strings.HasPrefix(input, "-") || strings.HasSuffix(input, "-") { 779 return false 780 } 781 782 return true 783} 784 785func (s *Server) handleDIDDocument(input string) http.HandlerFunc { 786 return func(w http.ResponseWriter, r *http.Request) { 787 if r.Method == "OPTIONS" { 788 return 789 } 790 791 // Resolve handle to DID 792 did, handleResolveTime, err := s.manager.ResolveHandleOrDID(r.Context(), input) 793 if err != nil { 794 if strings.Contains(err.Error(), "appears to be a handle") { 795 sendJSON(w, 400, map[string]string{ 796 "error": "Handle resolver not configured", 797 "hint": "Start server with --handle-resolver flag", 798 }) 799 } else { 800 sendJSON(w, 400, map[string]string{"error": err.Error()}) 801 } 802 return 803 } 804 805 resolvedHandle := "" 806 if handleResolveTime > 0 { 807 resolvedHandle = input 808 } 809 810 // Single call gets both document AND operation metadata 811 result, err := s.manager.ResolveDID(r.Context(), did) 812 if err != nil { 813 if strings.Contains(err.Error(), "deactivated") { 814 sendJSON(w, 410, map[string]string{"error": "DID has been deactivated"}) 815 } else if strings.Contains(err.Error(), "not found") { 816 sendJSON(w, 404, map[string]string{"error": "DID not found"}) 817 } else { 818 sendJSON(w, 500, map[string]string{"error": err.Error()}) 819 } 820 return 821 } 822 823 // Early ETag check - operation is already in result.LatestOperation 824 if result.LatestOperation != nil { 825 etag := fmt.Sprintf(`"%s"`, result.LatestOperation.CID) 826 827 if match := r.Header.Get("If-None-Match"); match != "" { 828 // Strip quotes if present 829 matchClean := strings.Trim(match, `"`) 830 if matchClean == result.LatestOperation.CID { 831 // Set minimal headers for 304 response 832 w.Header().Set("ETag", etag) 833 w.Header().Set("Cache-Control", "public, max-age=300") 834 w.WriteHeader(http.StatusNotModified) 835 return 836 } 837 } 838 } 839 840 // Set all headers (now with result.LatestOperation available) 841 setDIDDocumentHeaders(w, r, did, resolvedHandle, result, handleResolveTime) 842 843 w.Header().Set("Content-Type", "application/did+ld+json") 844 sendJSON(w, 200, result.Document) 845 } 846} 847 848func (s *Server) handleDIDData(input string) http.HandlerFunc { 849 return func(w http.ResponseWriter, r *http.Request) { 850 // Resolve handle to DID 851 did, _, err := s.manager.ResolveHandleOrDID(r.Context(), input) 852 if err != nil { 853 sendJSON(w, 400, map[string]string{"error": err.Error()}) 854 return 855 } 856 857 operations, _, err := s.manager.GetDIDOperations(context.Background(), did, false) 858 if err != nil { 859 sendJSON(w, 500, map[string]string{"error": err.Error()}) 860 return 861 } 862 863 if len(operations) == 0 { 864 sendJSON(w, 404, map[string]string{"error": "DID not found"}) 865 return 866 } 867 868 state, err := plcclient.BuildDIDState(did, operations) 869 if err != nil { 870 if strings.Contains(err.Error(), "deactivated") { 871 sendJSON(w, 410, map[string]string{"error": "DID has been deactivated"}) 872 } else { 873 sendJSON(w, 500, map[string]string{"error": err.Error()}) 874 } 875 return 876 } 877 878 sendJSON(w, 200, state) 879 } 880} 881 882func (s *Server) handleDIDAuditLog(input string) http.HandlerFunc { 883 return func(w http.ResponseWriter, r *http.Request) { 884 did, _, err := s.manager.ResolveHandleOrDID(r.Context(), input) 885 if err != nil { 886 sendJSON(w, 400, map[string]string{"error": err.Error()}) 887 return 888 } 889 890 operations, _, err := s.manager.GetDIDOperations(context.Background(), did, false) 891 if err != nil { 892 sendJSON(w, 500, map[string]string{"error": err.Error()}) 893 return 894 } 895 896 if len(operations) == 0 { 897 sendJSON(w, 404, map[string]string{"error": "DID not found"}) 898 return 899 } 900 901 auditLog := plcclient.FormatAuditLog(operations) 902 sendJSON(w, 200, auditLog) 903 } 904} 905 906// handleOperation gets a single operation with detailed timing headers 907func (s *Server) handleOperation() http.HandlerFunc { 908 return func(w http.ResponseWriter, r *http.Request) { 909 pointer := r.PathValue("pointer") 910 911 // Parse pointer format: "bundle:position" or global position 912 bundleNum, position, err := parseOperationPointer(pointer) 913 if err != nil { 914 sendJSON(w, 400, map[string]string{"error": err.Error()}) 915 return 916 } 917 918 // Validate position range 919 if position < 0 || position >= types.BUNDLE_SIZE { 920 sendJSON(w, 400, map[string]string{ 921 "error": fmt.Sprintf("Position must be 0-%d", types.BUNDLE_SIZE-1), 922 }) 923 return 924 } 925 926 // Time the entire request 927 totalStart := time.Now() 928 929 // Time the operation load 930 loadStart := time.Now() 931 op, err := s.manager.LoadOperation(r.Context(), bundleNum, position) 932 loadDuration := time.Since(loadStart) 933 934 if err != nil { 935 if strings.Contains(err.Error(), "not in index") || 936 strings.Contains(err.Error(), "not found") { 937 sendJSON(w, 404, map[string]string{"error": "Operation not found"}) 938 } else { 939 sendJSON(w, 500, map[string]string{"error": err.Error()}) 940 } 941 return 942 } 943 944 totalDuration := time.Since(totalStart) 945 946 // Calculate global position 947 globalPos := (bundleNum * types.BUNDLE_SIZE) + position 948 949 // Calculate operation age 950 opAge := time.Since(op.CreatedAt) 951 952 // Set response headers with useful metadata 953 setOperationHeaders(w, op, bundleNum, position, globalPos, loadDuration, totalDuration, opAge) 954 955 // Send raw JSON if available (faster, preserves exact format) 956 if len(op.RawJSON) > 0 { 957 w.Header().Set("Content-Type", "application/json") 958 w.Write(op.RawJSON) 959 } else { 960 sendJSON(w, 200, op) 961 } 962 } 963} 964 965// parseOperationPointer parses pointer in format "bundle:position" or global position 966func parseOperationPointer(pointer string) (bundleNum, position int, err error) { 967 // Check if it's the "bundle:position" format 968 if strings.Contains(pointer, ":") { 969 parts := strings.Split(pointer, ":") 970 if len(parts) != 2 { 971 return 0, 0, fmt.Errorf("invalid pointer format: use 'bundle:position' or global position") 972 } 973 974 bundleNum, err = strconv.Atoi(parts[0]) 975 if err != nil { 976 return 0, 0, fmt.Errorf("invalid bundle number: %w", err) 977 } 978 979 position, err = strconv.Atoi(parts[1]) 980 if err != nil { 981 return 0, 0, fmt.Errorf("invalid position: %w", err) 982 } 983 984 if bundleNum < 1 { 985 return 0, 0, fmt.Errorf("bundle number must be >= 1") 986 } 987 988 return bundleNum, position, nil 989 } 990 991 // Parse as global position 992 globalPos, err := strconv.Atoi(pointer) 993 if err != nil { 994 return 0, 0, fmt.Errorf("invalid position: must be number or 'bundle:position' format") 995 } 996 997 if globalPos < 0 { 998 return 0, 0, fmt.Errorf("global position must be >= 0") 999 } 1000 1001 // Handle small numbers as shorthand for bundle 1 1002 if globalPos < types.BUNDLE_SIZE { 1003 return 1, globalPos, nil 1004 } 1005 1006 // Convert global position to bundle + position 1007 bundleNum = globalPos / types.BUNDLE_SIZE 1008 position = globalPos % types.BUNDLE_SIZE 1009 1010 // Minimum bundle number is 1 1011 if bundleNum < 1 { 1012 bundleNum = 1 1013 } 1014 1015 return bundleNum, position, nil 1016} 1017 1018// setOperationHeaders sets useful response headers 1019func setOperationHeaders( 1020 w http.ResponseWriter, 1021 op *plcclient.PLCOperation, 1022 bundleNum, position, globalPos int, 1023 loadDuration, totalDuration, opAge time.Duration, 1024) { 1025 // === Location Information === 1026 w.Header().Set("X-Bundle-Number", fmt.Sprintf("%d", bundleNum)) 1027 w.Header().Set("X-Position", fmt.Sprintf("%d", position)) 1028 w.Header().Set("X-Global-Position", fmt.Sprintf("%d", globalPos)) 1029 w.Header().Set("X-Pointer", fmt.Sprintf("%d:%d", bundleNum, position)) 1030 1031 // === Operation Metadata === 1032 w.Header().Set("X-Operation-DID", op.DID) 1033 w.Header().Set("X-Operation-CID", op.CID) 1034 w.Header().Set("X-Operation-Created", op.CreatedAt.Format(time.RFC3339)) 1035 w.Header().Set("X-Operation-Age-Seconds", fmt.Sprintf("%d", int(opAge.Seconds()))) 1036 1037 // Nullification status 1038 if op.IsNullified() { 1039 w.Header().Set("X-Operation-Nullified", "true") 1040 if nullCID := op.GetNullifyingCID(); nullCID != "" { 1041 w.Header().Set("X-Operation-Nullified-By", nullCID) 1042 } 1043 } else { 1044 w.Header().Set("X-Operation-Nullified", "false") 1045 } 1046 1047 // === Size Information === 1048 if len(op.RawJSON) > 0 { 1049 w.Header().Set("X-Operation-Size", fmt.Sprintf("%d", len(op.RawJSON))) 1050 } 1051 1052 // === Performance Metrics (in milliseconds with precision) === 1053 w.Header().Set("X-Load-Time-Ms", fmt.Sprintf("%.3f", float64(loadDuration.Microseconds())/1000.0)) 1054 w.Header().Set("X-Total-Time-Ms", fmt.Sprintf("%.3f", float64(totalDuration.Microseconds())/1000.0)) 1055 1056 // === Caching Hints === 1057 // Set cache control (operations are immutable once bundled) 1058 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 1059 w.Header().Set("ETag", op.CID) // CID is perfect for ETag 1060} 1061 1062// handleDIDIndexStats returns detailed DID index performance metrics 1063func (s *Server) handleDebugDIDIndex() http.HandlerFunc { 1064 return func(w http.ResponseWriter, r *http.Request) { 1065 didStats := s.manager.GetDIDIndexStats() 1066 1067 if !didStats["enabled"].(bool) || !didStats["exists"].(bool) { 1068 sendJSON(w, 404, map[string]string{ 1069 "error": "DID index not available", 1070 }) 1071 return 1072 } 1073 1074 // Return all stats (more detailed than /status) 1075 sendJSON(w, 200, didStats) 1076 } 1077} 1078 1079func (s *Server) handleDebugResolver() http.HandlerFunc { 1080 return func(w http.ResponseWriter, r *http.Request) { 1081 resolverStats := s.manager.GetResolverStats() 1082 1083 if resolverStats == nil { 1084 sendJSON(w, 404, map[string]string{ 1085 "error": "Resolver not enabled", 1086 }) 1087 return 1088 } 1089 1090 // Return all stats (more detailed than /status) 1091 sendJSON(w, 200, resolverStats) 1092 } 1093}