sourcemaps
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m5s

This commit is contained in:
mats committed 2026-09-19 16:24:34 +02:00
1 parent 97743ea21c
commit 1aa264675e
11 files changed
+538 -17

No files matched your search

+137 -2
View File
@@ -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
}