package sourcemaps import ( "encoding/base64" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" sm "github.com/go-sourcemap/sourcemap" ) var frameRE = regexp.MustCompile(`(.*?)(https?://[^ )]+|[^ )]+):(\d+):(\d+)(\)?\s*)$`) type Resolver struct { dir string mu sync.RWMutex maps map[string]*sm.Consumer } func New(dir string) (*Resolver, error) { if dir == "" { dir = "./sourcemaps" } if err := os.MkdirAll(dir, 0750); err != nil { return nil, err } r := &Resolver{dir: dir, maps: make(map[string]*sm.Consumer)} r.load() return r, nil } // fileName encodes release and source reversibly so maps can be reloaded // from disk after a restart. func fileName(release, source string) string { enc := base64.RawURLEncoding return enc.EncodeToString([]byte(release)) + "." + enc.EncodeToString([]byte(normalize(source))) + ".map" } func parseFileName(name string) (release, source string, ok bool) { parts := strings.Split(strings.TrimSuffix(name, ".map"), ".") if len(parts) != 2 || !strings.HasSuffix(name, ".map") { return "", "", false } rel, err1 := base64.RawURLEncoding.DecodeString(parts[0]) src, err2 := base64.RawURLEncoding.DecodeString(parts[1]) if err1 != nil || err2 != nil { return "", "", false } return string(rel), string(src), true } // load restores previously uploaded maps; unreadable or legacy-named files are skipped. func (r *Resolver) load() { entries, err := os.ReadDir(r.dir) if err != nil { return } for _, e := range entries { release, source, ok := parseFileName(e.Name()) if !ok || e.IsDir() { continue } b, err := os.ReadFile(filepath.Join(r.dir, e.Name())) if err != nil { continue } consumer, err := sm.Parse(source+".map", b) if err != nil { continue } r.maps[key(release, source)] = consumer } } // Entry describes one loaded source map. type Entry struct { Release string `json:"release"` Source string `json:"source"` } // List returns every loaded map, sorted by release then source. func (r *Resolver) List() []Entry { r.mu.RLock() defer r.mu.RUnlock() out := make([]Entry, 0, len(r.maps)) for k := range r.maps { release, source, _ := strings.Cut(k, "\x00") out = append(out, Entry{Release: release, Source: source}) } sort.Slice(out, func(i, j int) bool { if out[i].Release != out[j].Release { return out[i].Release < out[j].Release } return out[i].Source < out[j].Source }) return out } func key(release, source string) string { return release + "\x00" + normalize(source) } func normalize(source string) string { source = strings.Split(source, "?")[0] if i := strings.Index(source, "/assets/"); i >= 0 { return source[i:] } return strings.TrimPrefix(source, "./") } func (r *Resolver) Upload(release, source string, body io.Reader) error { if strings.TrimSpace(release) == "" || strings.TrimSpace(source) == "" { return fmt.Errorf("release and source are required") } b, err := io.ReadAll(io.LimitReader(body, 50<<20)) if err != nil { return err } consumer, err := sm.Parse(source+".map", b) if err != nil { return fmt.Errorf("invalid source map: %w", err) } name := filepath.Join(r.dir, fileName(release, source)) if err := os.WriteFile(name, b, 0600); err != nil { return err } r.mu.Lock() r.maps[key(release, source)] = consumer r.mu.Unlock() return nil } func safe(s string) string { s = strings.ReplaceAll(s, "/", "_") s = strings.ReplaceAll(s, "\\", "_") return strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune("._-", r) { return r } return '_' }, s) } func (r *Resolver) ResolveStack(release, stack string) string { if release == "" || stack == "" { return stack } r.mu.RLock() defer r.mu.RUnlock() lines := strings.Split(stack, "\n") for i, line := range lines { m := frameRE.FindStringSubmatch(line) if m == nil { continue } genLine, _ := strconv.Atoi(m[3]) genCol, _ := strconv.Atoi(m[4]) consumer := r.maps[key(release, m[2])] if consumer == nil { continue } source, name, srcLine, srcCol, ok := consumer.Source(genLine, genCol) if !ok { continue } label := source if name != "" { label += " (" + name + ")" } lines[i] = fmt.Sprintf("%s%s:%d:%d%s [mapped from %s:%d:%d]", m[1], label, srcLine, srcCol, m[5], m[2], genLine, genCol) } return strings.Join(lines, "\n") } // Frame is one parsed stack frame, with original-source details when a map // for its file was uploaded. Status explains why a frame is or is not mapped: // "mapped", "no-release", "no-map" (no map uploaded for this file/release) or // "unmapped" (a map exists but has no mapping for this position). type Frame struct { Raw string `json:"raw"` Function string `json:"function,omitempty"` File string `json:"file"` Line int `json:"line"` Column int `json:"column"` Status string `json:"status"` Source string `json:"source,omitempty"` Name string `json:"name,omitempty"` SrcLine int `json:"srcLine,omitempty"` SrcColumn int `json:"srcColumn,omitempty"` Context []string `json:"context,omitempty"` // original source lines around SrcLine ContextTop int `json:"contextTop,omitempty"` // 1-based line number of Context[0] } const frameContext = 6 // ResolveFrames parses every frame in the stack and maps it through the // uploaded source maps, including surrounding original source when the map // embeds sourcesContent. func (r *Resolver) ResolveFrames(release, stack string) []Frame { r.mu.RLock() defer r.mu.RUnlock() var frames []Frame for _, line := range strings.Split(stack, "\n") { m := frameRE.FindStringSubmatch(line) if m == nil { continue } ln, _ := strconv.Atoi(m[3]) col, _ := strconv.Atoi(m[4]) fn := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(m[1]), "at "), "(")) f := Frame{Raw: strings.TrimSpace(line), Function: fn, File: m[2], Line: ln, Column: col, Status: "mapped"} switch consumer := r.maps[key(release, m[2])]; { case release == "": f.Status = "no-release" case consumer == nil: f.Status = "no-map" default: source, name, sl, sc, ok := consumer.Source(ln, col) if !ok { f.Status = "unmapped" break } f.Source, f.Name, f.SrcLine, f.SrcColumn = source, name, sl, sc if content := consumer.SourceContent(source); content != "" { src := strings.Split(content, "\n") top := max(sl-frameContext, 1) bottom := min(sl+frameContext, len(src)) if top <= bottom { f.Context, f.ContextTop = src[top-1:bottom], top } } } frames = append(frames, f) } return frames }