diff --git a/internal/vendor/vendor.go b/internal/vendor/vendor.go index 5a9ce27..8603514 100644 --- a/internal/vendor/vendor.go +++ b/internal/vendor/vendor.go @@ -29,9 +29,9 @@ import ( type vendorer struct { config *cfg.Config - fetched map[string]*fetched // canonical URL -> fetched file - aliases map[string]string // requested URL -> canonical URL - misses map[string]error // requested URL -> error, to skip retrying known-bad URLs + files map[string]*file // canonical URL -> fetched file + aliases map[string]string // requested URL -> canonical URL + misses map[string]error // requested URL -> error, to skip retrying known-bad URLs warnings []string } @@ -40,16 +40,15 @@ type vendorer struct { // skip rather than a hard failure. var errSkip = errors.New("unsupported response") -// fetched is the cached result of pass 1 for a single URL. -type fetched struct { +// file is the cached result of pass 1 for a single URL. +type file struct { url *url.URL content []byte filename string - vendorRel string // path on disk relative to v.config.Unpm.Out (host/dir/filename) + path string // path on disk relative to v.config.Unpm.Out (host/dir/filename) deps map[string]string // import spec as written in source -> canonical dep URL - sourceMap string // canonical URL of referenced source map, or "" - - typesRef string // canonical URL of the file's types (x-typescript-types header, else sidecar .d.ts), or "" + sourceMap string // canonical URL of referenced source map + types string // canonical URL of the file's types } func Vendor(c *cfg.Config) ([]string, error) { @@ -60,7 +59,7 @@ func Vendor(c *cfg.Config) ([]string, error) { v := &vendorer{ config: c, - fetched: make(map[string]*fetched), + files: make(map[string]*file), aliases: make(map[string]string), misses: make(map[string]error), } @@ -75,6 +74,7 @@ func Vendor(c *cfg.Config) ([]string, error) { } return v.warnings, fmt.Errorf("fetching %q: %w", key, err) } + entries[key] = canon } @@ -90,14 +90,14 @@ func Vendor(c *cfg.Config) ([]string, error) { v.warnings = append(v.warnings, fmt.Sprintf("failed to download types for %q: %v", key, err)) continue } - if f := v.fetched[entry]; f != nil { - f.typesRef = tcanon + if f := v.files[entry]; f != nil { + f.types = tcanon } } // pass 2: write each fetched file to disk at host/dir/filename, rewriting // imports to relative paths between vendor locations - for canon := range v.fetched { + for canon := range v.files { if err := v.writeOne(canon); err != nil { return v.warnings, fmt.Errorf("writing %s: %w", canon, err) } @@ -106,21 +106,21 @@ func Vendor(c *cfg.Config) ([]string, error) { importMap := make(map[string]string) // import key -> URL path within out directory typesMap := make(map[string]string) // import key -> vendor-relative types path for key, entry := range entries { - f := v.fetched[entry] + f := v.files[entry] if f == nil { continue } - importMap[key] = path.Join(c.Unpm.Root, f.vendorRel) + importMap[key] = path.Join(c.Unpm.Root, f.path) // types: explicit config entry / x-typescript-types header / sidecar .d.ts > entry file itself typesCanon := entry - if f.typesRef != "" { - typesCanon = f.typesRef + if f.types != "" { + typesCanon = f.types } - if tf, ok := v.fetched[typesCanon]; ok && typesCanon != entry { - typesMap[key] = "./" + tf.vendorRel + if tf, ok := v.files[typesCanon]; ok && typesCanon != entry { + typesMap[key] = "./" + tf.path } else { - typesMap[key] = "./" + f.vendorRel + typesMap[key] = "./" + f.path } } @@ -229,7 +229,7 @@ func (v *vendorer) fetch(rawURL string) (canon string, err error) { } // x-typescript-types is on this response, not the canonical one - v.attachTypesHeader(u, resp, rawURL, v.fetched[canon]) + v.attachTypesHeader(u, resp, rawURL, v.files[canon]) return canon, nil } @@ -269,14 +269,14 @@ func (v *vendorer) fetch(rawURL string) (canon string, err error) { canon = u.String() v.aliases[rawURL] = canon - f := &fetched{ - url: u, - content: body, - filename: filename, - vendorRel: path.Join(localDir, filename), - deps: make(map[string]string), + f := &file{ + url: u, + content: body, + filename: filename, + path: path.Join(localDir, filename), + deps: make(map[string]string), } - v.fetched[canon] = f + v.files[canon] = f v.discover(u, f) @@ -285,10 +285,10 @@ func (v *vendorer) fetch(rawURL string) (canon string, err error) { // if the response didn't already point us at types, try a "sidecar" .d.ts at // the same URL with the extension replaced (e.g. foo.mjs -> foo.d.ts). 404s // are expected and silent. - if f.typesRef == "" { + if f.types == "" { if sURL := sidecarURL(u, f.filename); sURL != "" { if scanon, err := v.fetch(sURL); err == nil { - f.typesRef = scanon + f.types = scanon } } } @@ -299,7 +299,7 @@ func (v *vendorer) fetch(rawURL string) (canon string, err error) { // discover enumerates the URLs a fetched file references — its imports and any // source map — fetching each and recording the edges on f. Source maps are // skipped; all other files use regex-based import scanning. -func (v *vendorer) discover(u *url.URL, f *fetched) { +func (v *vendorer) discover(u *url.URL, f *file) { if !isSourceMap(f.filename) { v.discoverRegex(u, f) } @@ -308,7 +308,7 @@ func (v *vendorer) discover(u *url.URL, f *fetched) { // discoverSourceMap follows a //# sourceMappingURL= comment, fetching the // referenced map and recording it on f.sourceMap. Inline data: URLs are skipped. -func (v *vendorer) discoverSourceMap(u *url.URL, f *fetched) { +func (v *vendorer) discoverSourceMap(u *url.URL, f *file) { for _, mapPath := range imports.ScanSourceMaps(string(f.content)) { ref, err := url.Parse(mapPath) if err != nil { @@ -328,88 +328,10 @@ func isSourceMap(filename string) bool { return strings.HasSuffix(strings.ToLower(filename), ".map") } -// resolveAndFetch fetches depURL, falling back to extension-search candidates -// when the importing file uses bundler-style or TypeScript-style resolution. -// For example, `import x from "./render"` in a .js file should match render.js; -// the same spec in a .ts file should also try render.ts / render.d.ts first. -// Failures are silent except for the last error returned. -func (v *vendorer) resolveAndFetch(depURL, parentFilename string) (string, error) { - if canon, ok := v.aliases[depURL]; ok { - return canon, nil - } - - canon, err := v.fetch(depURL) - if err == nil { - return canon, nil - } - - candidates := resolveCandidates(depURL, parentFilename) - if len(candidates) == 0 { - return "", err - } - - sawSkip := errors.Is(err, errSkip) - for _, candidate := range candidates { - c, e := v.fetch(candidate) - if e == nil { - v.aliases[depURL] = c - return c, nil - } - if errors.Is(e, errSkip) { - sawSkip = true - } - } - if sawSkip { - return "", errSkip - } - return "", err -} - -// resolveCandidates returns the fallback URLs to try when depURL can't be -// fetched as-is. TS parents try TS extensions before JS; JS parents try JS -// only; unrecognized parents return nil. -func resolveCandidates(depURL, parentFilename string) []string { - if !isScript(parentFilename) { - return nil - } - u, err := url.Parse(depURL) - if err != nil { - return nil - } - ext := strings.ToLower(path.Ext(u.Path)) - parentExt := path.Ext(strings.ToLower(parentFilename)) - isTSParent := parentExt == ".ts" || parentExt == ".tsx" || parentExt == ".mts" - - var suffixes []string - switch { - case isTSParent && ext == ".js": - suffixes = []string{".ts", ".tsx", ".d.ts"} - case isTSParent && ext == ".mjs": - suffixes = []string{".mts", ".d.mts"} - case isTSParent && ext == ".jsx": - suffixes = []string{".tsx"} - case isTSParent && ext == "": - suffixes = []string{".ts", ".tsx", ".d.ts", "/index.ts", "/index.tsx", "/index.d.ts", ".js", ".mjs", "/index.js", "/index.mjs"} - case ext == "": - suffixes = []string{".js", ".mjs", "/index.js", "/index.mjs"} - default: - return nil - } - - base := strings.TrimSuffix(u.Path, ext) - out := make([]string, 0, len(suffixes)) - for _, s := range suffixes { - nu := *u - nu.Path = base + s - out = append(out, nu.String()) - } - return out -} - // attachTypesHeader follows the x-typescript-types header on resp and records // the result on f.typesRef. The header is authoritative, so it overwrites any // sidecar already found. Errors are non-fatal — a warning is emitted. -func (v *vendorer) attachTypesHeader(u *url.URL, resp *http.Response, rawURL string, f *fetched) { +func (v *vendorer) attachTypesHeader(u *url.URL, resp *http.Response, rawURL string, f *file) { typesURL := resp.Header.Get("x-typescript-types") if typesURL == "" { return @@ -423,7 +345,7 @@ func (v *vendorer) attachTypesHeader(u *url.URL, resp *http.Response, rawURL str return } if f != nil { - f.typesRef = tcanon + f.types = tcanon } } @@ -458,7 +380,7 @@ func sidecarURL(u *url.URL, filename string) string { // discoverRegex enumerates imports by scanning the source with a regex. // Bare specifiers are skipped because imports.Resolve returns "" for anything // without a scheme or a "/", "./", "../" prefix. -func (v *vendorer) discoverRegex(u *url.URL, f *fetched) { +func (v *vendorer) discoverRegex(u *url.URL, f *file) { for _, spec := range imports.Scan(string(f.content)) { if _, seen := f.deps[spec]; seen { continue @@ -467,7 +389,7 @@ func (v *vendorer) discoverRegex(u *url.URL, f *fetched) { if depURL == "" { continue } - depCanon, err := v.resolveAndFetch(depURL, f.filename) + depCanon, err := v.fetch(depURL) if err != nil { v.warnings = append(v.warnings, fmt.Sprintf("failed to download %s: %v", depURL, err)) continue @@ -477,7 +399,7 @@ func (v *vendorer) discoverRegex(u *url.URL, f *fetched) { } func (v *vendorer) writeOne(canon string) error { - f := v.fetched[canon] + f := v.files[canon] if f == nil { return nil } @@ -488,7 +410,7 @@ func (v *vendorer) writeOne(canon string) error { } else { content = v.rewrite(f) } - return v.writeFile(canon, f.vendorRel, content) + return v.writeFile(canon, f.path, content) } func (v *vendorer) writeFile(canon, rel string, content []byte) error { @@ -513,16 +435,16 @@ func (v *vendorer) writeFile(canon, rel string, content []byte) error { // rewrite substitutes import specs and the sourceMappingURL in f.content with // paths relative to f.vendorRel using the vendor locations of f's deps. -func (v *vendorer) rewrite(f *fetched) []byte { - currentDir := path.Dir(f.vendorRel) +func (v *vendorer) rewrite(f *file) []byte { + currentDir := path.Dir(f.path) rewrites := make(map[string]string, len(f.deps)) for spec, dep := range f.deps { - df, ok := v.fetched[dep] + df, ok := v.files[dep] if !ok { continue } - rewrites[spec] = relPath(currentDir, df.vendorRel) + rewrites[spec] = relPath(currentDir, df.path) } result := imports.Rewrite(string(f.content), func(spec string) (string, bool) { @@ -540,12 +462,12 @@ func (v *vendorer) rewrite(f *fetched) []byte { // rewriteSourceMapURL points the //# sourceMappingURL comment at the vendored // copy of the map, leaving inline data: URLs untouched. func (v *vendorer) rewriteSourceMapURL(content, currentDir, sourceMap string) string { - df, ok := v.fetched[sourceMap] + df, ok := v.files[sourceMap] if !ok { return content } return imports.RewriteSourceMap(content, func(string) string { - return relPath(currentDir, df.vendorRel) + return relPath(currentDir, df.path) }) } diff --git a/internal/vendor/vendor_test.go b/internal/vendor/vendor_test.go index 6d887b5..92469b8 100644 --- a/internal/vendor/vendor_test.go +++ b/internal/vendor/vendor_test.go @@ -582,96 +582,6 @@ func TestVendor_ParentImport(t *testing.T) { } } -func TestVendor_JSExtensionResolution(t *testing.T) { - // A .js file that imports `./render` (no extension) should resolve to - // render.js, and `./util` should resolve to util/index.js — the way - // bundlers do for Node-style source. - srv := newTestServer(map[string]testFile{ - "/entry.js": {body: `export { x } from "./render"; export { y } from "./util";`}, - "/render.js": {body: `export const x = 1;`}, - "/util/index.js": {body: `export const y = 2;`}, - }) - defer srv.Close() - - outDir := filepath.Join(t.TempDir(), "vendor") - c := &cfg.Config{ - Imports: map[string]string{"lib": srv.URL + "/entry.js"}, - Unpm: cfg.Options{Out: outDir, Root: "/"}, - } - if _, err := vendor.Vendor(c); err != nil { - t.Fatal(err) - } - - host := strings.TrimPrefix(srv.URL, "http://") - - if _, err := os.Stat(filepath.Join(outDir, host, "render.js")); err != nil { - t.Fatalf("render.js not vendored: %v", err) - } - if _, err := os.Stat(filepath.Join(outDir, host, "util", "index.js")); err != nil { - t.Fatalf("util/index.js not vendored: %v", err) - } - - data, _ := os.ReadFile(filepath.Join(outDir, host, "entry.js")) - if !strings.Contains(string(data), `"./render.js"`) { - t.Fatalf("expected rewrite to ./render.js, got: %s", data) - } - if !strings.Contains(string(data), `"./util/index.js"`) { - t.Fatalf("expected rewrite to ./util/index.js, got: %s", data) - } -} - -func TestVendor_TSExtensionResolution(t *testing.T) { - // A .ts file that imports "./internal" with no extension should resolve to - // internal.ts on the server. The .ts files are vendored as-is (no transpilation); - // the extensionless import is rewritten to the resolved .ts filename. - srv := newTestServer(map[string]testFile{ - "/index.ts": {body: `import { x } from "./internal"; export const y: number = x;`}, - "/internal.ts": {body: `export const x: number = 1;`}, - }) - defer srv.Close() - - outDir := filepath.Join(t.TempDir(), "vendor") - c := &cfg.Config{ - Imports: map[string]string{"lib": srv.URL + "/index.ts"}, - Unpm: cfg.Options{Out: outDir, Root: "/"}, - } - if _, err := vendor.Vendor(c); err != nil { - t.Fatal(err) - } - - host := strings.TrimPrefix(srv.URL, "http://") - - // Only the original .ts files should exist on disk — no transpiled .js - for _, name := range []string{"index.ts", "internal.ts"} { - if _, err := os.Stat(filepath.Join(outDir, host, name)); err != nil { - t.Fatalf("%s not written: %v", name, err) - } - } - for _, name := range []string{"index.js", "internal.js"} { - if _, err := os.Stat(filepath.Join(outDir, host, name)); !os.IsNotExist(err) { - t.Fatalf("%s should not be written (no transpilation)", name) - } - } - - // The .ts file should have its extensionless import rewritten to ./internal.ts - tsData, _ := os.ReadFile(filepath.Join(outDir, host, "index.ts")) - if !strings.Contains(string(tsData), `"./internal.ts"`) { - t.Fatalf("expected import rewritten to ./internal.ts, got: %s", tsData) - } - - // importmap.json should point at the .ts file directly - imData, _ := os.ReadFile(filepath.Join(outDir, "importmap.json")) - if !strings.Contains(string(imData), `"/`+host+`/index.ts"`) { - t.Fatalf("importmap should point at /%s/index.ts, got: %s", host, imData) - } - - // jsconfig.json should point at the .ts file for types - jcData, _ := os.ReadFile(filepath.Join(outDir, "jsconfig.json")) - if !strings.Contains(string(jcData), `"./`+host+`/index.ts"`) { - t.Fatalf("jsconfig should reference ./%s/index.ts, got: %s", host, jcData) - } -} - func TestVendor_HTMLContentTypeSkipped(t *testing.T) { // A URL that returns text/html (typically the wrong URL — e.g. a github.com // blob page instead of a raw file) should be skipped with a warning instead @@ -728,44 +638,6 @@ func TestVendor_HTMLContentTypeSkipped(t *testing.T) { } } -func TestVendor_TypeOnlyImport(t *testing.T) { - // `import type` statements are matched by the same regex as regular imports, - // so they are discovered and their deps fetched. The import must be rewritten - // in the vendored .ts so it resolves to the actual file on disk. - srv := newTestServer(map[string]testFile{ - "/entry.ts": {body: `import type { Foo } from "./types"; export const x: Foo = null as any;`}, - "/types.ts": {body: `export type Foo = number;`}, - }) - defer srv.Close() - - outDir := filepath.Join(t.TempDir(), "vendor") - c := &cfg.Config{ - Imports: map[string]string{"entry": srv.URL + "/entry.ts"}, - Unpm: cfg.Options{Out: outDir, Root: "/"}, - } - if _, err := vendor.Vendor(c); err != nil { - t.Fatal(err) - } - - host := strings.TrimPrefix(srv.URL, "http://") - - // types.ts must be fetched so TypeScript can resolve the type-only import - if _, err := os.Stat(filepath.Join(outDir, host, "types.ts")); err != nil { - t.Fatalf("types.ts (referenced only by `import type`) not fetched: %v", err) - } - - // No transpiled .js should exist - if _, err := os.Stat(filepath.Join(outDir, host, "entry.js")); !os.IsNotExist(err) { - t.Fatalf("entry.js should not be written (no transpilation)") - } - - // The vendored .ts should have the extensionless import rewritten to ./types.ts - tsData, _ := os.ReadFile(filepath.Join(outDir, host, "entry.ts")) - if !strings.Contains(string(tsData), `import type { Foo } from "./types.ts"`) { - t.Fatalf("type-only import should be rewritten to ./types.ts, got: %s", tsData) - } -} - func TestVendor_SidecarDts(t *testing.T) { // When a script's URL has a sibling .d.ts file, it should be downloaded // automatically and used as the types path in jsconfig.json.