242 lines
6.6 KiB
Go
242 lines
6.6 KiB
Go
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
|
|
}
|