sourcemaps
This commit is contained in:
1 parent
97743ea21c
commit
1aa264675e
11 files changed
+538
-17
No files matched your search
@@ -1,11 +1,13 @@
|
||||
package sourcemaps
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -28,7 +30,76 @@ func New(dir string) (*Resolver, error) {
|
||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Resolver{dir: dir, maps: make(map[string]*sm.Consumer)}, nil
|
||||
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) }
|
||||
@@ -53,7 +124,7 @@ func (r *Resolver) Upload(release, source string, body io.Reader) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid source map: %w", err)
|
||||
}
|
||||
name := filepath.Join(r.dir, safe(release)+"-"+safe(normalize(source))+".map")
|
||||
name := filepath.Join(r.dir, fileName(release, source))
|
||||
if err := os.WriteFile(name, b, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -104,3 +175,67 @@ func (r *Resolver) ResolveStack(release, stack string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package sourcemaps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testMap = `{"version":3,"file":"a.js","sources":["src/a.ts"],"sourcesContent":["const a = 1\nthrow new Error('x')\n"],"names":[],"mappings":"AAAA;AACA"}`
|
||||
|
||||
func TestReloadFromDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r, _ := New(dir)
|
||||
if err := r.Upload("rel1", "https://h/assets/a.js", strings.NewReader(testMap)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r2, _ := New(dir) // simulates a restart
|
||||
if l := r2.List(); len(l) != 1 || l[0].Release != "rel1" || l[0].Source != "/assets/a.js" {
|
||||
t.Fatalf("list after reload = %+v", l)
|
||||
}
|
||||
frames := r2.ResolveFrames("rel1", "Error: x\n at f (https://h/assets/a.js:2:1)")
|
||||
if len(frames) != 1 || frames[0].Status != "mapped" || frames[0].Source != "src/a.ts" || len(frames[0].Context) == 0 {
|
||||
t.Fatalf("frames = %+v", frames)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user