Skip to content

Commit

Permalink
fix #1745, fix #3183: sources are URLs, not paths
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Feb 6, 2025
1 parent 96e70f5 commit 131d9b6
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 75 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,16 @@
.a{.b{color:red}:where(& .b){color:#00f}}
```

* Fix some correctness issues with source maps ([#3982](https://github.com/evanw/esbuild/issues/3982))
* Fix some correctness issues with source maps ([#1745](https://github.com/evanw/esbuild/issues/1745), [#3183](https://github.com/evanw/esbuild/issues/3183), [#3613](https://github.com/evanw/esbuild/issues/3613), [#3982](https://github.com/evanw/esbuild/issues/3982))

Previously esbuild incorrectly treated source map path references as file paths instead of as URLs. With this release, esbuild will now treat source map path references as URLs. This fixes the following problems with source maps:

* File names in `sourceMappingURL` that contained a space previously did not encode the space as `%20`, which resulted in JavaScript tools (including esbuild) failing to read that path back in when consuming the generated output file. This should now be fixed.

* Absolute URLs in `sourceMappingURL` that use the `file://` scheme previously attempted to read from a folder called `file:`. These URLs should now be recognized and parsed correctly.

* Entries in the `sources` array in the source map are now treated as URLs instead of file paths. The correct behavior for this is much more clear now that source maps has a [formal specification](https://tc39.es/ecma426/). Many thanks to those who worked on the specification.

* Fix incorrect package for `@esbuild/netbsd-arm64` ([#4018](https://github.com/evanw/esbuild/issues/4018))

Due to a copy+paste typo, the binary published to `@esbuild/netbsd-arm64` was not actually for `arm64`, and didn't run in that environment. This release should fix running esbuild in that environment (NetBSD on 64-bit ARM). Sorry about the mistake.
Expand Down
46 changes: 11 additions & 35 deletions internal/bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,16 +645,10 @@ func parseFile(args parseArgs) {
// Attempt to fill in null entries using the file system
for i, source := range sourceMap.Sources {
if sourceMap.SourcesContent[i].Value == nil {
var absPath string
if args.fs.IsAbs(source) {
absPath = source
} else if path.Namespace == "file" {
absPath = args.fs.Join(args.fs.Dir(path.Text), source)
} else {
continue
}
if contents, err, _ := args.caches.FSCache.ReadFile(args.fs, absPath); err == nil {
sourceMap.SourcesContent[i].Value = helpers.StringToUTF16(contents)
if sourceURL, err := url.Parse(source); err == nil && helpers.IsFileURL(sourceURL) {
if contents, err, _ := args.caches.FSCache.ReadFile(args.fs, helpers.FilePathFromFileURL(args.fs, sourceURL)); err == nil {
sourceMap.SourcesContent[i].Value = helpers.StringToUTF16(contents)
}
}
}
}
Expand Down Expand Up @@ -847,40 +841,22 @@ func extractSourceMapFromComment(
log.AddID(logger.MsgID_SourceMap_UnsupportedSourceMapComment, logger.Warning, tracker, comment.Range,
fmt.Sprintf("Unsupported source map comment: Unsupported host %q in file URL", commentURL.Host))
return logger.Path{}, nil
} else if commentURL.Scheme == "file" && strings.HasPrefix(commentURL.Path, "/") {
} else if helpers.IsFileURL(commentURL) {
// Handle absolute file URLs
absPath = commentURL.Path
absPath = helpers.FilePathFromFileURL(fs, commentURL)
} else if absResolveDir == "" {
// Fail if plugins don't set a resolve directory
log.AddID(logger.MsgID_SourceMap_UnsupportedSourceMapComment, logger.Debug, tracker, comment.Range,
"Unsupported source map comment: Cannot resolve relative URL without a resolve directory")
return logger.Path{}, nil
} else {
// Append a trailing slash so that resolving the URL includes the trailing
// directory, and turn Windows-style paths with volumes into URL-style paths:
//
// "/Users/User/Desktop" => "/Users/User/Desktop/"
// "C:\\Users\\User\\Desktop" => "/C:/Users/User/Desktop/"
//
absResolveDir = strings.ReplaceAll(absResolveDir, "\\", "/")
if !strings.HasPrefix(absResolveDir, "/") {
absResolveDir = fmt.Sprintf("/%s/", absResolveDir)
} else {
absResolveDir += "/"
}

// Join the (potentially relative) URL path from the comment text
// to the resolve directory path to form the final absolute path
absResolveURL := url.URL{Scheme: "file", Path: absResolveDir}
absPath = absResolveURL.ResolveReference(commentURL).Path
}

// Convert URL-style paths back into Windows-style paths if needed:
//
// "/C:/Users/User/foo.js.map" => "C:/Users/User/foo.js.map"
//
if !strings.HasPrefix(fs.Cwd(), "/") {
absPath = strings.TrimPrefix(absPath, "/")
absResolveURL := helpers.FileURLFromFilePath(absResolveDir)
if !strings.HasSuffix(absResolveURL.Path, "/") {
absResolveURL.Path += "/"
}
absPath = helpers.FilePathFromFileURL(fs, absResolveURL.ResolveReference(commentURL))
}

// Try to read the file contents
Expand Down
41 changes: 40 additions & 1 deletion internal/helpers/path.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package helpers

import "strings"
import (
"net/url"
"strings"

"github.com/evanw/esbuild/internal/fs"
)

func IsInsideNodeModules(path string) bool {
for {
Expand All @@ -20,3 +25,37 @@ func IsInsideNodeModules(path string) bool {
path = dir
}
}

func IsFileURL(fileURL *url.URL) bool {
return fileURL.Scheme == "file" && (fileURL.Host == "" || fileURL.Host == "localhost") && strings.HasPrefix(fileURL.Path, "/")
}

func FileURLFromFilePath(filePath string) *url.URL {
// Append a trailing slash so that resolving the URL includes the trailing
// directory, and turn Windows-style paths with volumes into URL-style paths:
//
// "/Users/User/Desktop" => "/Users/User/Desktop/"
// "C:\\Users\\User\\Desktop" => "/C:/Users/User/Desktop/"
//
filePath = strings.ReplaceAll(filePath, "\\", "/")
if !strings.HasPrefix(filePath, "/") {
filePath = "/" + filePath
}

return &url.URL{Scheme: "file", Path: filePath}
}

func FilePathFromFileURL(fs fs.FS, fileURL *url.URL) string {
path := fileURL.Path

// Convert URL-style paths back into Windows-style paths if needed:
//
// "/C:/Users/User/foo.js.map" => "C:\\Users\\User\\foo.js.map"
//
if !strings.HasPrefix(fs.Cwd(), "/") {
path = strings.TrimPrefix(path, "/")
path = strings.ReplaceAll(path, "/", "\\") // This is needed for "filepath.Rel()" to work
}

return path
}
56 changes: 51 additions & 5 deletions internal/js_parser/sourcemap_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package js_parser

import (
"fmt"
"net/url"
"sort"
"strings"

"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/helpers"
Expand All @@ -26,10 +28,12 @@ func ParseSourceMap(log logger.Log, source logger.Source) *sourcemap.SourceMap {
}

var sources []string
var sourcesArray []js_ast.Expr
var sourcesContent []sourcemap.SourceContent
var names []string
var mappingsRaw []uint16
var mappingsStart int32
var sourceRoot string
hasVersion := false

for _, prop := range obj.Properties {
Expand All @@ -51,14 +55,18 @@ func ParseSourceMap(log logger.Log, source logger.Source) *sourcemap.SourceMap {
mappingsStart = prop.ValueOrNil.Loc.Start + 1
}

case "sourceRoot":
if value, ok := prop.ValueOrNil.Data.(*js_ast.EString); ok {
sourceRoot = helpers.UTF16ToString(value.Value)
}

case "sources":
if value, ok := prop.ValueOrNil.Data.(*js_ast.EArray); ok {
sources = []string{}
for _, item := range value.Items {
sources = make([]string, len(value.Items))
sourcesArray = value.Items
for i, item := range value.Items {
if element, ok := item.Data.(*js_ast.EString); ok {
sources = append(sources, helpers.UTF16ToString(element.Value))
} else {
sources = append(sources, "")
sources[i] = helpers.UTF16ToString(element.Value)
}
}
}
Expand Down Expand Up @@ -256,6 +264,44 @@ func ParseSourceMap(log logger.Log, source logger.Source) *sourcemap.SourceMap {
sort.Stable(mappings)
}

// Try resolving relative source URLs into absolute source URLs.
// See https://tc39.es/ecma426/#resolving-sources for details.
var sourceURLPrefix string
var baseURL *url.URL
if sourceRoot != "" {
if index := strings.LastIndexByte(sourceRoot, '/'); index != -1 {
sourceURLPrefix = sourceRoot[:index+1]
} else {
sourceURLPrefix = sourceRoot + "/"
}
}
if source.KeyPath.Namespace == "file" {
baseURL = &url.URL{Scheme: "file", Path: source.KeyPath.Text}
}
for i, sourcePath := range sources {
if sourcePath == "" {
continue // Skip null entries
}
sourcePath = sourceURLPrefix + sourcePath
sourceURL, err := url.Parse(sourcePath)

// Report URL parse errors (such as "%XY" being an invalid escape)
if err != nil {
if urlErr, ok := err.(*url.Error); ok {
err = urlErr.Err // Use the underlying error to reduce noise
}
log.AddID(logger.MsgID_SourceMap_InvalidSourceURL, logger.Warning, &tracker, source.RangeOfString(sourcesArray[i].Loc),
fmt.Sprintf("Invalid source URL: %s", err.Error()))
continue
}

// Resolve this URL relative to the enclosing directory
if baseURL != nil {
sourceURL = baseURL.ResolveReference(sourceURL)
}
sources[i] = sourceURL.String()
}

return &sourcemap.SourceMap{
Sources: sources,
SourcesContent: sourcesContent,
Expand Down
37 changes: 16 additions & 21 deletions internal/linker/linker.go
Original file line number Diff line number Diff line change
Expand Up @@ -6957,8 +6957,7 @@ func (c *linkerContext) generateSourceMapForChunk(

// Generate the "sources" and "sourcesContent" arrays
type item struct {
path logger.Path
prettyPath string
source string
quotedContents []byte
}
items := make([]item, 0, len(results))
Expand All @@ -6979,9 +6978,12 @@ func (c *linkerContext) generateSourceMapForChunk(
if !c.options.ExcludeSourcesContent {
quotedContents = dataForSourceMaps[result.sourceIndex].QuotedContents[0]
}
source := file.InputFile.Source.KeyPath.Text
if file.InputFile.Source.KeyPath.Namespace == "file" {
source = helpers.FileURLFromFilePath(source).String()
}
items = append(items, item{
path: file.InputFile.Source.KeyPath,
prettyPath: file.InputFile.Source.PrettyPath,
source: source,
quotedContents: quotedContents,
})
nextSourcesIndex++
Expand All @@ -6991,24 +6993,12 @@ func (c *linkerContext) generateSourceMapForChunk(
// Complex case: nested source map
sm := file.InputFile.InputSourceMap
for i, source := range sm.Sources {
path := logger.Path{
Namespace: file.InputFile.Source.KeyPath.Namespace,
Text: source,
}

// If this file is in the "file" namespace, change the relative path in
// the source map into an absolute path using the directory of this file
if path.Namespace == "file" {
path.Text = c.fs.Join(c.fs.Dir(file.InputFile.Source.KeyPath.Text), source)
}

var quotedContents []byte
if !c.options.ExcludeSourcesContent {
quotedContents = dataForSourceMaps[result.sourceIndex].QuotedContents[i]
}
items = append(items, item{
path: path,
prettyPath: source,
source: source,
quotedContents: quotedContents,
})
}
Expand All @@ -7024,14 +7014,19 @@ func (c *linkerContext) generateSourceMapForChunk(

// Modify the absolute path to the original file to be relative to the
// directory that will contain the output file for this chunk
if item.path.Namespace == "file" {
if relPath, ok := c.fs.Rel(chunkAbsDir, item.path.Text); ok {
if sourceURL, err := url.Parse(item.source); err == nil && helpers.IsFileURL(sourceURL) {
sourcePath := helpers.FilePathFromFileURL(c.fs, sourceURL)
if relPath, ok := c.fs.Rel(chunkAbsDir, sourcePath); ok {
// Make sure to always use forward slashes, even on Windows
item.prettyPath = strings.ReplaceAll(relPath, "\\", "/")
relativeURL := url.URL{Path: strings.ReplaceAll(relPath, "\\", "/")}
item.source = relativeURL.String()

// Replace certain percent encodings for better readability
item.source = strings.ReplaceAll(item.source, "%20", " ")
}
}

j.AddBytes(helpers.QuoteForJSON(item.prettyPath, c.options.ASCIIOnly))
j.AddBytes(helpers.QuoteForJSON(item.source, c.options.ASCIIOnly))
}
j.AddString("]")

Expand Down
15 changes: 10 additions & 5 deletions internal/logger/msg_ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ const (

// Source maps
MsgID_SourceMap_InvalidSourceMappings
MsgID_SourceMap_SectionsInSourceMap
MsgID_SourceMap_InvalidSourceURL
MsgID_SourceMap_MissingSourceMap
MsgID_SourceMap_SectionsInSourceMap
MsgID_SourceMap_UnsupportedSourceMapComment

// package.json
Expand Down Expand Up @@ -207,10 +208,12 @@ func StringToMsgIDs(str string, logLevel LogLevel, overrides map[MsgID]LogLevel)
// Source maps
case "invalid-source-mappings":
overrides[MsgID_SourceMap_InvalidSourceMappings] = logLevel
case "sections-in-source-map":
overrides[MsgID_SourceMap_SectionsInSourceMap] = logLevel
case "invalid-source-url":
overrides[MsgID_SourceMap_InvalidSourceURL] = logLevel
case "missing-source-map":
overrides[MsgID_SourceMap_MissingSourceMap] = logLevel
case "sections-in-source-map":
overrides[MsgID_SourceMap_SectionsInSourceMap] = logLevel
case "unsupported-source-map-comment":
overrides[MsgID_SourceMap_UnsupportedSourceMapComment] = logLevel

Expand Down Expand Up @@ -341,10 +344,12 @@ func MsgIDToString(id MsgID) string {
// Source maps
case MsgID_SourceMap_InvalidSourceMappings:
return "invalid-source-mappings"
case MsgID_SourceMap_SectionsInSourceMap:
return "sections-in-source-map"
case MsgID_SourceMap_InvalidSourceURL:
return "invalid-source-url"
case MsgID_SourceMap_MissingSourceMap:
return "missing-source-map"
case MsgID_SourceMap_SectionsInSourceMap:
return "sections-in-source-map"
case MsgID_SourceMap_UnsupportedSourceMapComment:
return "unsupported-source-map-comment"

Expand Down
Loading

0 comments on commit 131d9b6

Please sign in to comment.