From 1295898df18bf29a0e57c52f697c46e35d58fc96 Mon Sep 17 00:00:00 2001 From: tatang26 Date: Mon, 18 May 2026 20:49:04 -0500 Subject: [PATCH] refactor(importmap): Implementing BundleJS API bundler to the importmap --- importmap/internal/bundlejs/bundlejs.go | 139 +++++ importmap/internal/bundlejs/bundlejs_test.go | 195 +++++++ importmap/internal/importmap/bundler.go | 12 + importmap/internal/importmap/importmap.go | 4 +- .../internal/importmap/importmap_test.go | 529 +++++++++--------- importmap/internal/importmap/manager.go | 29 +- importmap/internal/importmap/pin.go | 302 +--------- importmap/internal/importmap/pin_test.go | 237 -------- importmap/internal/importmap/pristine.go | 6 +- importmap/internal/importmap/save.go | 24 + importmap/internal/importmap/unpin.go | 40 +- importmap/internal/jspm/jspm.go | 90 --- 12 files changed, 667 insertions(+), 940 deletions(-) create mode 100644 importmap/internal/bundlejs/bundlejs.go create mode 100644 importmap/internal/bundlejs/bundlejs_test.go create mode 100644 importmap/internal/importmap/bundler.go delete mode 100644 importmap/internal/importmap/pin_test.go create mode 100644 importmap/internal/importmap/save.go delete mode 100644 importmap/internal/jspm/jspm.go diff --git a/importmap/internal/bundlejs/bundlejs.go b/importmap/internal/bundlejs/bundlejs.go new file mode 100644 index 0000000..059ff7d --- /dev/null +++ b/importmap/internal/bundlejs/bundlejs.go @@ -0,0 +1,139 @@ +// Package internal provides bundler implementations used by the importmap manager. +// Each bundler must satisfy the Bundler interface defined in the parent package. +package bundlejs + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "sync" +) + +// baseURL is the root endpoint for the bundleJS API. +const baseURL = "https://deno.bundlejs.com" + +// Client is the Bundler implementation backed by deno.bundleJS.com. +// It resolves package versions and fetches minified ES module bundles +// in parallel, one goroutine per package. +type Client struct{} + +// Bundle resolves and fetches each package concurrently. +// It returns a map of "name@version" -> minified JS file contents. +// All packages are processed in parallel; the first error encountered +// is returned and the partial map is discarded. +func (c *Client) Bundle(ctx context.Context, packages ...string) (map[string][]byte, error) { + var mu sync.Mutex + var wg sync.WaitGroup + + pkgMap := make(map[string][]byte) + errCh := make(chan error, len(packages)) + + for pkg := range slices.Values(packages) { + wg.Add(1) + go func() { + defer wg.Done() + + pkgName, err := c.resolveVersion(ctx, pkg) + if err != nil { + errCh <- err + return + } + + content, err := c.fetchBundle(ctx, pkgName) + if err != nil { + errCh <- err + return + } + + mu.Lock() + defer mu.Unlock() + pkgMap[pkgName] = content + }() + } + + wg.Wait() + close(errCh) + + for err := range errCh { + if err != nil { + return nil, err + } + } + + return pkgMap, nil +} + +// resolveVersion calls the bundleJS metadata endpoint to determine the exact resolved +// version for pkg (which may include a semver range or no version at all). +// It returns the full "name@version" spec, e.g.: +// +// "some-package" -> "some-package@1.2.3" +// "@scope/pkg@^2" -> "@scope/pkg@2.0.0" +func (c *Client) resolveVersion(ctx context.Context, pkg string) (string, error) { + params := make(url.Values) + params.Set("q", pkg) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"?"+params.Encode(), nil) + if err != nil { + return "", err + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("resolve: unexpected status %d for %q", res.StatusCode, pkg) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + var meta struct { + Version string `json:"version"` + } + + if err := json.Unmarshal(body, &meta); err != nil { + return "", fmt.Errorf("resolve: parse response JSON: %w", err) + } + + if meta.Version == "" { + return "", fmt.Errorf("resolve: response contains no version field for %q", pkg) + } + + return meta.Version, nil +} + +// fetchBundle downloads the minified ES module bundle for the given "name@version" spec +// from the bundleJS file endpoint and returns the raw bytes. +func (c *Client) fetchBundle(ctx context.Context, pkg string) ([]byte, error) { + params := make(url.Values) + params.Set("q", pkg) + params.Set("file", "true") + params.Set("minify", "true") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"?"+params.Encode(), nil) + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch: unexpected status %d for %q", res.StatusCode, pkg) + } + + return io.ReadAll(res.Body) +} diff --git a/importmap/internal/bundlejs/bundlejs_test.go b/importmap/internal/bundlejs/bundlejs_test.go new file mode 100644 index 0000000..b9074e7 --- /dev/null +++ b/importmap/internal/bundlejs/bundlejs_test.go @@ -0,0 +1,195 @@ +package bundlejs_test + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "go.leapkit.dev/tools/importmap/internal/bundlejs" +) + +// roundTripper is a test http.RoundTripper backed by a function. +type roundTripper func(*http.Request) (*http.Response, error) + +func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return r(req) } + +// withTransport installs a mock HTTP transport for the duration of the test. +func withTransport(t *testing.T, fn roundTripper) { + t.Helper() + orig := http.DefaultClient + http.DefaultClient = &http.Client{Transport: fn} + t.Cleanup(func() { http.DefaultClient = orig }) +} + +// mockTransport returns a RoundTripper that handles bundlejs requests: +// - resolveVersion: GET /?q=pkg -> {"version":"name@version"} +// - fetchBundle: GET /?q=...&file=true -> JS bundle bytes +// +// The resolved version defaults to 1.0.0 unless pkg already has @version. +func mockTransport(bundleBody string) roundTripper { + return func(req *http.Request) (*http.Response, error) { + q := req.URL.Query() + pkg := q.Get("q") + + if q.Get("file") == "true" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(bundleBody)), + }, nil + } + + // resolveVersion: echo back full spec + version := "1.0.0" + name := pkg + if idx := strings.LastIndex(pkg, "@"); idx > 0 { + version = pkg[idx+1:] + name = pkg[:idx] + } + body := fmt.Sprintf(`{"version":%q}`, name+"@"+version) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } +} + +func TestBundleResolvesVersion(t *testing.T) { + withTransport(t, mockTransport("/* bundle */")) + + result, err := new(bundlejs.Client).Bundle(context.Background(), "some-package") + if err != nil { + t.Fatalf("Bundle: %v", err) + } + + if _, ok := result["some-package@1.0.0"]; !ok { + t.Errorf("expected key some-package@1.0.0, got keys: %v", keys(result)) + } +} + +func TestBundleRespectsExplicitVersion(t *testing.T) { + withTransport(t, mockTransport("/* bundle */")) + + result, err := new(bundlejs.Client).Bundle(context.Background(), "some-package@2.3.4") + if err != nil { + t.Fatalf("Bundle: %v", err) + } + + if _, ok := result["some-package@2.3.4"]; !ok { + t.Errorf("expected key some-package@2.3.4, got keys: %v", keys(result)) + } +} + +func TestBundleScopedPackage(t *testing.T) { + withTransport(t, mockTransport("/* bundle */")) + + result, err := new(bundlejs.Client).Bundle(context.Background(), "@scope/pkg") + if err != nil { + t.Fatalf("Bundle: %v", err) + } + + if _, ok := result["@scope/pkg@1.0.0"]; !ok { + t.Errorf("expected key @scope/pkg@1.0.0, got keys: %v", keys(result)) + } +} + +func TestBundleReturnsFileContent(t *testing.T) { + withTransport(t, mockTransport("var x=1;")) + + result, err := new(bundlejs.Client).Bundle(context.Background(), "some-package") + if err != nil { + t.Fatalf("Bundle: %v", err) + } + + content := result["some-package@1.0.0"] + if string(content) != "var x=1;" { + t.Errorf("content = %q, want %q", content, "var x=1;") + } +} + +func TestBundleMultiplePackages(t *testing.T) { + withTransport(t, mockTransport("/* bundle */")) + + result, err := new(bundlejs.Client).Bundle(context.Background(), "pkg-a", "pkg-b", "pkg-c") + if err != nil { + t.Fatalf("Bundle: %v", err) + } + + if len(result) != 3 { + t.Errorf("expected 3 entries, got %d: %v", len(result), keys(result)) + } +} + +func TestBundleErrorOnResolveFailure(t *testing.T) { + withTransport(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("")), + }, nil + }) + + _, err := new(bundlejs.Client).Bundle(context.Background(), "no-such-package") + if err == nil { + t.Error("expected error, got nil") + } +} + +func TestBundleErrorOnFetchFailure(t *testing.T) { + withTransport(t, func(req *http.Request) (*http.Response, error) { + if req.URL.Query().Get("file") == "true" { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("")), + }, nil + } + // resolveVersion succeeds + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"version":"some-package@1.0.0"}`)), + }, nil + }) + + _, err := new(bundlejs.Client).Bundle(context.Background(), "some-package") + if err == nil { + t.Error("expected error on fetch failure, got nil") + } +} + +func TestBundleErrorOnMalformedJSON(t *testing.T) { + withTransport(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("not json")), + }, nil + }) + + _, err := new(bundlejs.Client).Bundle(context.Background(), "some-package") + if err == nil { + t.Error("expected error for malformed JSON, got nil") + } +} + +func TestBundleErrorOnMissingVersionField(t *testing.T) { + withTransport(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"size":1234}`)), + }, nil + }) + + _, err := new(bundlejs.Client).Bundle(context.Background(), "some-package") + if err == nil { + t.Error("expected error for missing version field, got nil") + } +} + +// keys returns the keys of a map for use in error messages. +func keys(m map[string][]byte) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/importmap/internal/importmap/bundler.go b/importmap/internal/importmap/bundler.go new file mode 100644 index 0000000..c249170 --- /dev/null +++ b/importmap/internal/importmap/bundler.go @@ -0,0 +1,12 @@ +package importmap + +import "context" + +// Bundler is the interface that wraps the Bundle method. +// Implementations are responsible for resolving package versions +// and fetching their bundled file contents. +type Bundler interface { + // Bundle resolves and fetches the given packages, returning a map of + // "name@version" -> file contents, e.g. "some-package@1.2.3" -> []byte. + Bundle(ctx context.Context, packages ...string) (map[string][]byte, error) +} diff --git a/importmap/internal/importmap/importmap.go b/importmap/internal/importmap/importmap.go index 3bae323..15769b5 100644 --- a/importmap/internal/importmap/importmap.go +++ b/importmap/internal/importmap/importmap.go @@ -9,7 +9,7 @@ import ( "syscall" "github.com/spf13/pflag" - "go.leapkit.dev/tools/importmap/internal/jspm" + "go.leapkit.dev/tools/importmap/internal/bundlejs" "go.leapkit.dev/tools/importmap/internal/npm" ) @@ -46,7 +46,7 @@ func Process(ctx context.Context) error { ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer cancel() - m := NewManager(containerFolder, jspm.Client(), npm.Client()) + m := NewManager(containerFolder, new(bundlejs.Client), npm.Client()) switch args[0] { case "pin": diff --git a/importmap/internal/importmap/importmap_test.go b/importmap/internal/importmap/importmap_test.go index 169f206..0e8d85b 100644 --- a/importmap/internal/importmap/importmap_test.go +++ b/importmap/internal/importmap/importmap_test.go @@ -23,7 +23,7 @@ func TestImportMapPin(t *testing.T) { t.Cleanup(func() { http.DefaultClient = &http.Client{} }) - mockedGenerator := &mockGenerator{} + mockedGenerator := &mockBundler{} mockAuditor := &mockAuditor{} t.Run("correct pinning a package", func(t *testing.T) { @@ -40,14 +40,14 @@ func TestImportMapPin(t *testing.T) { t.Errorf("Expected nil, got error %v", err) } - expected := `{ "imports": { "pkg": "vendor/pkg@1.0.0/index.js" } }` + expected := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` current := strings.Join(strings.Fields(string(m.JSON())), " ") if current != expected { t.Errorf("Expected %q, got %q", expected, current) } - if _, err := os.Stat("vendor/pkg@1.0.0/index.js"); err != nil { + if _, err := os.Stat("vendor/pkg@1.0.0/bundle.min.js"); err != nil { t.Errorf("Expected nil, got error %v", err) } }) @@ -70,9 +70,9 @@ func TestImportMapPin(t *testing.T) { expected := ` { "imports": { - "@pkg/one": "vendor/@pkg/one@1.0.0/index.js", - "@pkg/three": "vendor/@pkg/three@1.0.0/index.js", - "@pkg/two": "vendor/@pkg/two@1.0.0/index.js" + "@pkg/one": "vendor/@pkg/one@1.0.0/bundle.min.js", + "@pkg/three": "vendor/@pkg/three@1.0.0/bundle.min.js", + "@pkg/two": "vendor/@pkg/two@1.0.0/bundle.min.js" } }` @@ -117,7 +117,7 @@ func TestImportMapPin(t *testing.T) { t.Errorf("Expected nil, got error %v", err) } - expected := `{ "imports": { "pkg": "vendor/pkg@1.2.0/index.js" } }` + expected := `{ "imports": { "pkg": "vendor/pkg@1.2.0/bundle.min.js" } }` current := strings.Join(strings.Fields(string(m.JSON())), " ") if current != expected { @@ -139,7 +139,7 @@ func TestImportMapPin(t *testing.T) { t.Errorf("Expected nil, got error %v", err) } - expected := `{ "imports": { "pkg": "vendor/pkg@1.0.0/index.js" } }` + expected := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` current := strings.Join(strings.Fields(string(m.JSON())), " ") if current != expected { @@ -151,7 +151,7 @@ func TestImportMapPin(t *testing.T) { t.Errorf("Expected nil, got error %v", err) } - expected = `{ "imports": { "pkg": "vendor/pkg@1.2.0/index.js" } }` + expected = `{ "imports": { "pkg": "vendor/pkg@1.2.0/bundle.min.js" } }` current = strings.Join(strings.Fields(string(m.JSON())), " ") if current != expected { @@ -195,7 +195,7 @@ func TestImportMapUnpin(t *testing.T) { os.RemoveAll("vendor") }) - mockedGenerator := &mockGenerator{} + mockedGenerator := &mockBundler{} mockAuditor := &mockAuditor{} ctx := context.Background() @@ -233,7 +233,7 @@ func TestImportMapUnpin(t *testing.T) { t.Errorf("Expected nil, got error %v", err) } - expected := `{ "imports": { "pkg": "vendor/pkg@1.0.0/index.js" } }` + expected := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` current := strings.Join(strings.Fields(string(m.JSON())), " ") if current != expected { @@ -259,7 +259,7 @@ func TestImportMapUpdate(t *testing.T) { os.RemoveAll("vendor") }) - mockedGenerator := &mockGenerator{} + mockedGenerator := &mockBundler{} mockAuditor := &mockAuditor{} ctx := context.Background() @@ -281,7 +281,7 @@ func TestImportMapUpdate(t *testing.T) { expected := ` { "imports": { - "pkg": "vendor/pkg@2.2.3/index.js" + "pkg": "vendor/pkg@2.2.3/bundle.min.js" } }` @@ -317,7 +317,7 @@ func TestImportMapPristine(t *testing.T) { os.RemoveAll("vendor") }) - currentImportMap := `{ "imports": { "pkg": "vendor/pkg@1.0.0/index.js" } }` + currentImportMap := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` f, _ := os.Create("importmap.json") f.WriteString(currentImportMap) @@ -327,7 +327,7 @@ func TestImportMapPristine(t *testing.T) { t.Errorf("Expected directory not to exist, got error %v", err) } - mockedGenerator := &mockGenerator{} + mockedGenerator := &mockBundler{} mockAuditor := &mockAuditor{} m := importmap.NewManager(".", mockedGenerator, mockAuditor) @@ -335,7 +335,7 @@ func TestImportMapPristine(t *testing.T) { t.Errorf("Expected nil, got error %v", err) } - if _, err := os.Stat("vendor/pkg@1.0.0/index.js"); os.IsNotExist(err) { + if _, err := os.Stat("vendor/pkg@1.0.0/bundle.min.js"); os.IsNotExist(err) { t.Error("Expected file to exist") } } @@ -353,7 +353,7 @@ func TestImportMapJSON(t *testing.T) { os.RemoveAll("vendor") }) - mockedGenerator := &mockGenerator{} + mockedGenerator := &mockBundler{} mockAuditor := &mockAuditor{} t.Run("correct JSON", func(t *testing.T) { @@ -361,7 +361,7 @@ func TestImportMapJSON(t *testing.T) { m.Pin(context.Background(), "@pkg/one", "@pkg/two", "@pkg/three") - expected := `{ "imports": { "@pkg/one": "vendor/@pkg/one@1.0.0/index.js", "@pkg/three": "vendor/@pkg/three@1.0.0/index.js", "@pkg/two": "vendor/@pkg/two@1.0.0/index.js" } }` + expected := `{ "imports": { "@pkg/one": "vendor/@pkg/one@1.0.0/bundle.min.js", "@pkg/three": "vendor/@pkg/three@1.0.0/bundle.min.js", "@pkg/two": "vendor/@pkg/two@1.0.0/bundle.min.js" } }` if strings.Join(strings.Fields(string(m.JSON())), " ") != expected { t.Errorf("Expected %q, got %q", expected, m.JSON()) @@ -399,7 +399,7 @@ func TestImportMapPackages(t *testing.T) { os.RemoveAll("vendor") }) - mockedGenerator := &mockGenerator{} + mockedGenerator := &mockBundler{} mockAuditor := &mockAuditor{} t.Run("correct printing packages", func(t *testing.T) { @@ -421,16 +421,16 @@ func TestImportMapPackages(t *testing.T) { var buf bytes.Buffer io.Copy(&buf, r) - if !strings.Contains(buf.String(), "@pkg/one to: vendor/@pkg/one@1.0.0/index.js") { - t.Errorf("Expected '@pkg/one to: vendor/@pkg/one@1.0.0/index.js', got '%v'", buf.String()) + if !strings.Contains(buf.String(), "@pkg/one to: vendor/@pkg/one@1.0.0/bundle.min.js") { + t.Errorf("Expected '@pkg/one to: vendor/@pkg/one@1.0.0/bundle.min.js', got '%v'", buf.String()) } - if !strings.Contains(buf.String(), "@pkg/two to: vendor/@pkg/two@1.0.0/index.js") { - t.Errorf("Expected '@pkg/two to: vendor/@pkg/two@1.0.0/index.js', got '%v'", buf.String()) + if !strings.Contains(buf.String(), "@pkg/two to: vendor/@pkg/two@1.0.0/bundle.min.js") { + t.Errorf("Expected '@pkg/two to: vendor/@pkg/two@1.0.0/bundle.min.js', got '%v'", buf.String()) } - if !strings.Contains(buf.String(), "@pkg/three to: vendor/@pkg/three@1.0.0/index.js") { - t.Errorf("Expected '@pkg/three to: vendor/@pkg/three@1.0.0/index.js', got '%v'", buf.String()) + if !strings.Contains(buf.String(), "@pkg/three to: vendor/@pkg/three@1.0.0/bundle.min.js") { + t.Errorf("Expected '@pkg/three to: vendor/@pkg/three@1.0.0/bundle.min.js', got '%v'", buf.String()) } }) } @@ -448,7 +448,7 @@ func TestImportMapAudit(t *testing.T) { os.RemoveAll("vendor") }) - mockedGenerator := &mockGenerator{} + mockedGenerator := &mockBundler{} mockAuditor := &mockAuditor{} t.Run("correct auditing packages", func(t *testing.T) { @@ -589,7 +589,7 @@ func TestImportMapOutdated(t *testing.T) { os.RemoveAll("vendor") }) - mockedGenerator := &mockGenerator{} + mockedGenerator := &mockBundler{} mockAuditor := &mockAuditor{} t.Run("correct outdated packages", func(t *testing.T) { @@ -682,206 +682,6 @@ func TestImportMapOutdated(t *testing.T) { }) } -func TestImportMapScopes(t *testing.T) { - os.Chdir(t.TempDir()) - - http.DefaultClient = &http.Client{ - Transport: &mockedRoundTripper{}, - } - - t.Cleanup(func() { - http.DefaultClient = &http.Client{} - os.Remove("importmap.json") - os.RemoveAll("vendor") - }) - - // Create a mock generator that returns scopes - mockGenWithScopes := &mockGeneratorWithScopes{} - mockAuditor := &mockAuditor{} - - ctx := context.Background() - - t.Run("correct pinning a package with scopes", func(t *testing.T) { - t.Cleanup(func() { - os.Remove("importmap.json") - os.RemoveAll("vendor") - }) - - m := importmap.NewManager(".", mockGenWithScopes, mockAuditor) - err := m.Pin(ctx, "@org/package-main") - if err != nil { - t.Errorf("Expected nil, got error %v", err) - } - - jsonBytes := m.JSON() - jsonStr := string(jsonBytes) - - if !strings.Contains(jsonStr, "@org/package-main") { - t.Error("Expected @org/package-main in JSON") - } - - if !strings.Contains(jsonStr, "scopes") { - t.Error("Expected scopes field in JSON") - } - - if !strings.Contains(jsonStr, "vendor/") { - t.Error("Expected vendor/ paths in scopes") - } - - if !strings.Contains(jsonStr, "@org/package-dep/utils") { - t.Error("Expected @org/package-dep/utils in scopes") - } - }) - - t.Run("unpinning a package removes it from imports", func(t *testing.T) { - t.Cleanup(func() { - os.Remove("importmap.json") - os.RemoveAll("vendor") - }) - - m := importmap.NewManager(".", mockGenWithScopes, mockAuditor) - m.Pin(ctx, "@org/package-main") - - // Verify scope exists before unpin - jsonBefore := string(m.JSON()) - if !strings.Contains(jsonBefore, "scopes") { - t.Fatal("Expected scopes before unpin") - } - - err := m.Unpin(ctx, "@org/package-main") - if err != nil { - t.Errorf("Expected nil, got error %v", err) - } - - jsonAfter := string(m.JSON()) - if strings.Contains(jsonAfter, "@org/package-main") { - t.Error("Expected @org/package-main to be removed from imports") - } - }) - - t.Run("JSON omits empty scopes", func(t *testing.T) { - t.Cleanup(func() { - os.Remove("importmap.json") - os.RemoveAll("vendor") - }) - - m := importmap.NewManager(".", &mockGenerator{}, mockAuditor) - m.Pin(ctx, "pkg") - - jsonBytes := m.JSON() - jsonStr := string(jsonBytes) - - // Should not contain "scopes" field if empty - if strings.Contains(jsonStr, "scopes") { - t.Errorf("Expected JSON to omit empty scopes field, got: %s", jsonStr) - } - }) - - t.Run("JSON includes scopes when present", func(t *testing.T) { - t.Cleanup(func() { - os.Remove("importmap.json") - os.RemoveAll("vendor") - }) - - m := importmap.NewManager(".", mockGenWithScopes, mockAuditor) - m.Pin(ctx, "@org/package-main") - - jsonBytes := m.JSON() - jsonStr := string(jsonBytes) - - // Should contain "scopes" field - if !strings.Contains(jsonStr, "scopes") { - t.Errorf("Expected JSON to include scopes field, got: %s", jsonStr) - } - }) - - t.Run("downloads staticDeps including special _/ files", func(t *testing.T) { - t.Cleanup(func() { - os.Remove("importmap.json") - os.RemoveAll("vendor") - }) - - mockGenWithStaticDeps := &mockGeneratorWithStaticDeps{} - m := importmap.NewManager(".", mockGenWithStaticDeps, mockAuditor) - - err := m.Pin(ctx, "@org/package-ext") - if err != nil { - t.Errorf("Expected nil, got error %v", err) - } - - // Verify the _/ file was downloaded to scopes (dependency of main package) - if _, err := os.Stat("vendor/@org/package-ext@2.5.0/scopes/@org/package-base@1.5.0/_/xyz789.js"); err != nil { - t.Errorf("Expected _/ file in scopes, got error: %v", err) - } - - // Verify the runtime file was downloaded to scopes - if _, err := os.Stat("vendor/@org/package-ext@2.5.0/scopes/@org/package-base@1.5.0/runtime/index.js"); err != nil { - t.Errorf("Expected runtime file in scopes, got error: %v", err) - } - - // Verify the main package file was downloaded to package root - if _, err := os.Stat("vendor/@org/package-ext@2.5.0/dist/index.js"); err != nil { - t.Errorf("Expected main package file to exist, got error: %v", err) - } - - // Verify JSON structure - internal files are NOT in importmap, only main package - jsonBytes := m.JSON() - jsonStr := string(jsonBytes) - - // Should only have the main package in imports, not the internal _/ files - if !strings.Contains(jsonStr, "@org/package-ext") { - t.Error("Expected main package in imports") - } - - if !strings.Contains(jsonStr, "vendor/@org/package-ext@2.5.0/dist/index.js") { - t.Errorf("Expected main package path in imports, got: %s", jsonStr) - } - }) -} - -// mockGeneratorWithStaticDeps returns imports with staticDeps including _/ files -type mockGeneratorWithStaticDeps struct{} - -func (g *mockGeneratorWithStaticDeps) Generate(ctx context.Context, packages ...string) (interface{}, error) { - imports := map[string]string{ - "@org/package-ext": "https://ga.jspm.io/npm:@org/package-ext@2.5.0/dist/index.js", - } - - staticDeps := []string{ - "https://ga.jspm.io/npm:@org/package-base@1.5.0/_/xyz789.js", - "https://ga.jspm.io/npm:@org/package-base@1.5.0/runtime/index.js", - "https://ga.jspm.io/npm:@org/package-ext@2.5.0/dist/index.js", - } - - return &mockGeneratorResult{ - imports: imports, - scopes: make(map[string]map[string]string), - staticDeps: staticDeps, - }, nil -} - -// mockGeneratorWithScopes returns both imports and scopes -type mockGeneratorWithScopes struct{} - -func (g *mockGeneratorWithScopes) Generate(ctx context.Context, packages ...string) (interface{}, error) { - imports := map[string]string{ - "@org/package-main": "https://ga.jspm.io/npm:@org/package-main@1.2.0/dist/index.js", - } - - scopes := map[string]map[string]string{ - "https://ga.jspm.io/": { - "@org/package-dep/utils": "https://ga.jspm.io/npm:@org/package-dep@2.3.0/dist/utils/index.js", - "package-helper": "https://ga.jspm.io/npm:package-helper@3.1.0/dist/index.js", - }, - } - - return &mockGeneratorResult{ - imports: imports, - scopes: scopes, - staticDeps: []string{}, // Empty by default - }, nil -} - func TestProcess(t *testing.T) { os.Chdir(t.TempDir()) @@ -1003,7 +803,7 @@ func TestProcess(t *testing.T) { os.Stdout = current }) - content := `{ "imports": { "pkg": "vendor/pkg@1.0.0/index.js" } }` + content := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` f, _ := os.Create("importmap.json") f.WriteString(content) @@ -1040,6 +840,207 @@ func TestProcess(t *testing.T) { } }) + t.Run("correct process unpin command", func(t *testing.T) { + t.Cleanup(func() { + os.Remove("importmap.json") + os.RemoveAll("vendor") + }) + + content := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` + f, _ := os.Create("importmap.json") + f.WriteString(content) + f.Close() + os.MkdirAll("vendor/pkg@1.0.0", 0o755) + + r, w, _ := os.Pipe() + current := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = current }) + + os.Args = []string{"importmap", "--importmap.folder=.", "unpin", "pkg"} + if err := importmap.Process(context.Background()); err != nil { + t.Errorf("Expected nil, got error %v", err) + } + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + + if !strings.Contains(buf.String(), "[info] Packages unpinned successfully") { + t.Errorf("Expected unpin success message, got %q", buf.String()) + } + }) + + t.Run("correct process unpin with no package", func(t *testing.T) { + r, w, _ := os.Pipe() + current := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = current }) + + os.Args = []string{"importmap", "--importmap.folder=.", "unpin"} + if err := importmap.Process(context.Background()); err != nil { + t.Errorf("Expected nil, got error %v", err) + } + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + + if !strings.Contains(buf.String(), "[info] importmap unpin ") { + t.Errorf("Expected unpin usage message, got %q", buf.String()) + } + }) + + t.Run("correct process update command", func(t *testing.T) { + t.Cleanup(func() { + os.Remove("importmap.json") + os.RemoveAll("vendor") + }) + + content := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` + f, _ := os.Create("importmap.json") + f.WriteString(content) + f.Close() + + r, w, _ := os.Pipe() + current := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = current }) + + os.Args = []string{"importmap", "--importmap.folder=.", "update"} + if err := importmap.Process(context.Background()); err != nil { + t.Errorf("Expected nil, got error %v", err) + } + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + + if !strings.Contains(buf.String(), "[info] Packages updated successfully") { + t.Errorf("Expected update success message, got %q", buf.String()) + } + }) + + t.Run("correct process pristine command", func(t *testing.T) { + t.Cleanup(func() { + os.Remove("importmap.json") + os.RemoveAll("vendor") + }) + + content := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` + f, _ := os.Create("importmap.json") + f.WriteString(content) + f.Close() + + r, w, _ := os.Pipe() + current := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = current }) + + os.Args = []string{"importmap", "--importmap.folder=.", "pristine"} + if err := importmap.Process(context.Background()); err != nil { + t.Errorf("Expected nil, got error %v", err) + } + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + + if !strings.Contains(buf.String(), "[info] Packages downloaded successfully") { + t.Errorf("Expected pristine success message, got %q", buf.String()) + } + }) + + t.Run("correct process json command", func(t *testing.T) { + t.Cleanup(func() { + os.Remove("importmap.json") + os.RemoveAll("vendor") + }) + + content := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` + f, _ := os.Create("importmap.json") + f.WriteString(content) + f.Close() + + r, w, _ := os.Pipe() + current := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = current }) + + os.Args = []string{"importmap", "--importmap.folder=.", "json"} + if err := importmap.Process(context.Background()); err != nil { + t.Errorf("Expected nil, got error %v", err) + } + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + + if !strings.Contains(buf.String(), "pkg") { + t.Errorf("Expected pkg in json output, got %q", buf.String()) + } + }) + + t.Run("correct process packages command", func(t *testing.T) { + t.Cleanup(func() { + os.Remove("importmap.json") + os.RemoveAll("vendor") + }) + + content := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` + f, _ := os.Create("importmap.json") + f.WriteString(content) + f.Close() + + r, w, _ := os.Pipe() + current := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = current }) + + os.Args = []string{"importmap", "--importmap.folder=.", "packages"} + if err := importmap.Process(context.Background()); err != nil { + t.Errorf("Expected nil, got error %v", err) + } + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + + if !strings.Contains(buf.String(), "pkg") { + t.Errorf("Expected pkg in packages output, got %q", buf.String()) + } + }) + + t.Run("correct process outdated command", func(t *testing.T) { + t.Cleanup(func() { + os.Remove("importmap.json") + os.RemoveAll("vendor") + }) + + content := `{ "imports": { "pkg": "vendor/pkg@1.0.0/bundle.min.js" } }` + f, _ := os.Create("importmap.json") + f.WriteString(content) + f.Close() + + r, w, _ := os.Pipe() + current := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = current }) + + os.Args = []string{"importmap", "--importmap.folder=.", "outdated"} + if err := importmap.Process(context.Background()); err != nil { + t.Errorf("Expected nil, got error %v", err) + } + + w.Close() + var buf bytes.Buffer + io.Copy(&buf, r) + + if !strings.Contains(buf.String(), "pkg") { + t.Errorf("Expected pkg in outdated output, got %q", buf.String()) + } + }) + t.Run("unknown command", func(t *testing.T) { os.Args = []string{"importmap", "--importmap.folder=.", "unknown_command"} @@ -1094,33 +1095,17 @@ func (a *mockAuditor) Outdated(ctx context.Context, packages map[string]string) return outdated, nil } -type mockGeneratorResult struct { - imports map[string]string - scopes map[string]map[string]string - staticDeps []string -} - -func (r *mockGeneratorResult) GetImports() map[string]string { - return r.imports -} +// mockBundler implements Bundler for tests. +// It returns "name@version" -> []byte("/* mock */") for each package, +// defaulting to version 1.0.0 when no version is specified. +type mockBundler struct{} -func (r *mockGeneratorResult) GetScopes() map[string]map[string]string { - return r.scopes -} - -func (r *mockGeneratorResult) GetStaticDeps() []string { - return r.staticDeps -} - -type mockGenerator struct{} - -func (g *mockGenerator) Generate(ctx context.Context, packages ...string) (interface{}, error) { +func (g *mockBundler) Bundle(ctx context.Context, packages ...string) (map[string][]byte, error) { if ctx.Value("generate_no_exists") != nil { pkg := "pkg" if len(packages) > 0 { pkg = packages[0] } - return nil, fmt.Errorf("Error: Unable to resolve npm:%s@ to a valid version", pkg) } @@ -1128,32 +1113,18 @@ func (g *mockGenerator) Generate(ctx context.Context, packages ...string) (inter return nil, fmt.Errorf("test error") } - pkgVersionRegex := regexp.MustCompile(`(.+)\@([\d\.]+)$`) + pkgVersionRegex := regexp.MustCompile(`^(.+)@([\d\.]+)$`) - m := map[string]string{} - staticDeps := []string{} - + result := make(map[string][]byte) for _, pkg := range packages { p := pkg - if !pkgVersionRegex.MatchString(p) { p += "@1.0.0" } - - matches := pkgVersionRegex.FindStringSubmatch(p) - - packageURL := fmt.Sprintf("https://ga.jspm.io/npm:%s/index.js", p) - m[matches[1]] = packageURL - - // Add the package URL to staticDeps (this is what JSPM returns) - staticDeps = append(staticDeps, packageURL) + result[p] = []byte("/* mock */") } - return &mockGeneratorResult{ - imports: m, - scopes: make(map[string]map[string]string), - staticDeps: staticDeps, - }, nil + return result, nil } type mockedRoundTripper struct{} @@ -1163,10 +1134,28 @@ func (m *mockedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error return nil, fmt.Errorf("download test error") } - if strings.HasPrefix(req.URL.String(), "https://api.jspm.io/generate") { + if req.URL.Host == "deno.bundlejs.com" { + q := req.URL.Query() + pkg := q.Get("q") + + // fetchBundle request: has file=true param. + if q.Get("file") == "true" { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("/* mock bundle */")), + }, nil + } + + // resolveVersion request: return full "name@version" spec. + // If pkg already has a version suffix, echo it back; otherwise default to 1.0.0. + version := "1.0.0" + if idx := strings.LastIndex(pkg, "@"); idx > 0 { + version = pkg[idx+1:] + pkg = pkg[:idx] + } return &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ "map":{ "imports":{ "pkg": "https://ga.jspm.io/npm:pkg@1.0.0/index.js" } } }`)), + Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"version":%q}`, pkg+"@"+version))), }, nil } @@ -1186,6 +1175,6 @@ func (m *mockedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error return &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader("file content")), + Body: io.NopCloser(strings.NewReader("/* mock */")), }, nil } diff --git a/importmap/internal/importmap/manager.go b/importmap/internal/importmap/manager.go index 42abdd3..b086441 100644 --- a/importmap/internal/importmap/manager.go +++ b/importmap/internal/importmap/manager.go @@ -10,19 +10,6 @@ import ( "strings" ) -// GeneratorResult contains the imports, scopes, and staticDeps returned by a package generator. -type GeneratorResult interface { - GetImports() map[string]string - GetScopes() map[string]map[string]string - GetStaticDeps() []string -} - -// Generator defines an interface for generating a map of dependencies from a list of packages. -// It returns a result that can provide imports and scopes for the given packages. -type Generator interface { - Generate(context.Context, ...string) (any, error) -} - // Auditor provides methods for analyzing dependencies for known vulnerabilities // and checking if they are outdated. type Auditor interface { @@ -31,8 +18,7 @@ type Auditor interface { } type modules struct { - Map map[string]string `json:"imports"` - Scopes map[string]map[string]string `json:"scopes,omitempty"` + Map map[string]string `json:"imports"` } func (m *modules) Bytes() []byte { @@ -44,8 +30,8 @@ type manager struct { containerFolder string modules modules - generator Generator - auditor Auditor + bundler Bundler + auditor Auditor } func (m *manager) Packages() { @@ -68,15 +54,14 @@ func (m *manager) JSON() []byte { return m.modules.Bytes() } -func NewManager(containerFolder string, generator Generator, auditor Auditor) *manager { +func NewManager(containerFolder string, bundler Bundler, auditor Auditor) *manager { m := &manager{ containerFolder: containerFolder, modules: modules{ - Map: make(map[string]string), - Scopes: make(map[string]map[string]string), + Map: make(map[string]string), }, - auditor: auditor, - generator: generator, + bundler: bundler, + auditor: auditor, } data, _ := os.ReadFile(filepath.Join(m.containerFolder, "importmap.json")) diff --git a/importmap/internal/importmap/pin.go b/importmap/internal/importmap/pin.go index e238093..c2b7d1a 100644 --- a/importmap/internal/importmap/pin.go +++ b/importmap/internal/importmap/pin.go @@ -3,309 +3,47 @@ package importmap import ( "context" "fmt" - "io" - "net/http" "os" "path/filepath" "strings" ) func (m *manager) Pin(ctx context.Context, packages ...string) error { - if len(packages) == 0 { - return nil - } - - for _, pkg := range packages { - if err := m.pinPackage(ctx, pkg); err != nil { - return err - } - } - - return m.write() -} - -func (m *manager) pinPackage(ctx context.Context, pkg string) error { - // Track files for rollback - var downloadedFiles []string - - // 1. Call JSPM - genResult, err := m.generator.Generate(ctx, pkg) + result, err := m.bundler.Bundle(ctx, packages...) if err != nil { return err } - result, ok := genResult.(GeneratorResult) - if !ok { - rollbackFiles(downloadedFiles) - return fmt.Errorf("generator returned unexpected type") - } - - imports := result.GetImports() - scopes := result.GetScopes() - staticDeps := result.GetStaticDeps() + pkgNames := []string{} + for nameVersion, content := range result { + // get only the package name: "@scope/some-package@1.2.3" -> "@scope/some-package" + pkgName := nameVersion + if idx := strings.LastIndex(nameVersion, "@"); idx > 0 { + pkgName = nameVersion[:idx] + } - // 2. Determine main package name and version from imports - // There should be exactly one entry in imports for the package we're pinning - var mainPkgName, mainVersion string - for packageName, packageURL := range imports { - // Unpin if already exists - if m.modules.Map[packageName] != "" { - if err := m.Unpin(ctx, packageName); err != nil { - rollbackFiles(downloadedFiles) + if _, ok := m.modules.Map[pkgName]; ok { + if err := m.Unpin(ctx, pkgName); err != nil { return err } } - // Parse to get main package info - pkgName, version, relativePath, err := parseJSPMURL(packageURL) - if err != nil { - rollbackFiles(downloadedFiles) - return fmt.Errorf("failed to parse package URL %s: %w", packageURL, err) - } + vendorPath := "vendor/" + nameVersion + "/bundle.min.js" + destPath := filepath.Join(m.containerFolder, vendorPath) - mainPkgName = pkgName - mainVersion = version - - // Add to imports with vendor prefix - vendorPath := "vendor/" + pkgName + "@" + version + "/" + relativePath - m.modules.Map[packageName] = vendorPath - } - - // 3. Download ALL files from staticDeps (this includes main + all dependencies) - downloadedFiles, err = downloadAllStaticDeps(ctx, mainPkgName, mainVersion, staticDeps, m.containerFolder) - if err != nil { - rollbackFiles(downloadedFiles) - return fmt.Errorf("failed to download files: %w", err) - } - - // 4. Add scopes to importmap - addScopesToImportMap(&m.modules, mainPkgName, mainVersion, scopes) - - fmt.Printf("[info] pinned %s (%d files)\n", pkg, len(downloadedFiles)) - - return nil -} - -// parseJSPMURL parses a JSPM URL into package name, version, and relative path -// Input: "https://ga.jspm.io/npm:@org/package@1.2.3/dist/index.js" -// Output: "@org/package", "1.2.3", "dist/index.js" -func parseJSPMURL(url string) (pkgName, version, relativePath string, err error) { - // Remove "https://ga.jspm.io/npm:" - rest := strings.TrimPrefix(url, "https://ga.jspm.io/npm:") - if rest == url { - return "", "", "", fmt.Errorf("not a JSPM URL: %s", url) - } - - // Handle scoped packages (@org/pkg@version/path) vs regular (pkg@version/path) - var pathAfterVersion string - - if strings.HasPrefix(rest, "@") { - // Scoped package: @org/package@1.2.3/dist/index.js - // Find second @ (version delimiter) - idx := strings.Index(rest[1:], "@") - if idx == -1 { - return "", "", "", fmt.Errorf("invalid scoped package format: %s", rest) + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("pin: write %s: %w", destPath, err) } - idx += 1 // Adjust for skipping first char - - pkgName = rest[:idx] - remainder := rest[idx+1:] // After second @ - // Split version from path - slashIdx := strings.Index(remainder, "/") - if slashIdx > 0 { - version = remainder[:slashIdx] - pathAfterVersion = remainder[slashIdx+1:] - } else { - version = remainder - pathAfterVersion = "" + if err := os.WriteFile(destPath, content, 0o644); err != nil { + return fmt.Errorf("pin: write %s: %w", destPath, err) } - } else { - // Regular package: pkg@4.5.6/index.js - parts := strings.SplitN(rest, "@", 2) - if len(parts) != 2 { - return "", "", "", fmt.Errorf("invalid package format: %s", rest) - } - - pkgName = parts[0] - remainder := parts[1] - - // Split version from path - slashIdx := strings.Index(remainder, "/") - if slashIdx > 0 { - version = remainder[:slashIdx] - pathAfterVersion = remainder[slashIdx+1:] - } else { - version = remainder - pathAfterVersion = "" - } - } - - // Handle case where URL ends with just package@version (no path) - if pathAfterVersion == "" { - pathAfterVersion = "index.js" // Default entry point - } - - return pkgName, version, pathAfterVersion, nil -} - -// downloadFile downloads a file from URL to destPath with JSPM header comment -func downloadFile(url, destPath string) error { - // Create directory structure - dir := filepath.Dir(destPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - // Download - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, url) - } - - content, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - // Prepend comment header - header := fmt.Sprintf("/* Downloaded from %s */\n\n", url) - fullContent := append([]byte(header), content...) - - return os.WriteFile(destPath, fullContent, 0644) -} - -// downloadAllStaticDeps downloads all files from staticDeps -// Returns list of downloaded file paths -func downloadAllStaticDeps( - ctx context.Context, - mainPkgName, mainVersion string, - staticDeps []string, - containerFolder string, -) ([]string, error) { - var downloadedFiles []string - - for _, fileURL := range staticDeps { - // Parse URL to get package name, version, and path - pkgName, version, relativePath, err := parseJSPMURL(fileURL) - if err != nil { - return downloadedFiles, fmt.Errorf("failed to parse URL %s: %w", fileURL, err) - } - - // Determine file path based on whether it's the main package or a dependency - var filePath string - if pkgName == mainPkgName && version == mainVersion { - // Main package file - download to package root - filePath = filepath.Join( - containerFolder, - "vendor", - pkgName+"@"+version, - relativePath, - ) - } else { - // Dependency file - download to scopes/ - filePath = filepath.Join( - containerFolder, - "vendor", - mainPkgName+"@"+mainVersion, - "scopes", - pkgName+"@"+version, - relativePath, - ) - } - - if err := downloadFile(fileURL, filePath); err != nil { - return downloadedFiles, fmt.Errorf("failed to download %s: %w", fileURL, err) - } - downloadedFiles = append(downloadedFiles, filePath) - } - - return downloadedFiles, nil -} - -// addScopesToImportMap adds scope entries to the importmap structure -func addScopesToImportMap( - modules *modules, - pkgName, version string, - allScopes map[string]map[string]string, -) { - // Find JSPM scope - var jspmScope map[string]string - for key, scope := range allScopes { - if strings.Contains(key, "ga.jspm.io") { - jspmScope = scope - break - } - } - - if jspmScope == nil { - return - } - - // Create scope key for this package - scopeKey := "vendor/" + pkgName + "@" + version + "/" - - if modules.Scopes == nil { - modules.Scopes = make(map[string]map[string]string) - } - if modules.Scopes[scopeKey] == nil { - modules.Scopes[scopeKey] = make(map[string]string) - } - - // Map each dependency - for depName, depURL := range jspmScope { - depPkgName, depVersion, depRelPath, err := parseJSPMURL(depURL) - if err != nil { - // Skip invalid URLs - continue - } - - scopeValue := "vendor/" + pkgName + "@" + version + "/scopes/" + - depPkgName + "@" + depVersion + "/" + depRelPath - - modules.Scopes[scopeKey][depName] = scopeValue - } -} - -// rollbackFiles deletes downloaded files and empty parent directories -func rollbackFiles(files []string) { - for _, file := range files { - os.Remove(file) - - // Remove empty parent directories up to vendor/ - dir := filepath.Dir(file) - for dir != "vendor" && dir != "." && dir != "/" { - if err := os.Remove(dir); err != nil { - break // Directory not empty or error, stop - } - dir = filepath.Dir(dir) - } - } -} - -func (m *manager) write() error { - // Clean empty scopes - if m.modules.Scopes != nil && len(m.modules.Scopes) == 0 { - m.modules.Scopes = nil - } - - outputPath := filepath.Join(m.containerFolder, "importmap.json") - if err := os.MkdirAll(filepath.Dir(outputPath), os.ModePerm); err != nil { - return err - } - - f, err := os.Create(outputPath) - if err != nil { - return err + m.modules.Map[pkgName] = vendorPath + pkgNames = append(pkgNames, pkgName) } - defer f.Close() - f.Write(m.modules.Bytes()) + fmt.Printf("[info] pinned %s\n", strings.Join(pkgNames, ", ")) - return nil + return m.save() } diff --git a/importmap/internal/importmap/pin_test.go b/importmap/internal/importmap/pin_test.go deleted file mode 100644 index 293cfe6..0000000 --- a/importmap/internal/importmap/pin_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package importmap_test - -import ( - "context" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - - "go.leapkit.dev/tools/importmap/internal/importmap" -) - -// mockScopedGenerator returns package with scopes (new structure) -type mockScopedGenerator struct{} - -func (g *mockScopedGenerator) Generate(ctx context.Context, packages ...string) (any, error) { - imports := map[string]string{ - "@org/package-a": "https://ga.jspm.io/npm:@org/package-a@1.0.0/dist/index.js", - } - - scopes := map[string]map[string]string{ - "https://ga.jspm.io/": { - "package-b": "https://ga.jspm.io/npm:package-b@2.0.0/dist/index.js", - "@org/package-c/utils": "https://ga.jspm.io/npm:@org/package-c@3.0.0/dist/utils/index.js", - }, - } - - // staticDeps contains ALL files to download (main + dependencies + internal files) - staticDeps := []string{ - "https://ga.jspm.io/npm:@org/package-a@1.0.0/dist/index.js", - "https://ga.jspm.io/npm:@org/package-a@1.0.0/_/abc123.js", // internal file - "https://ga.jspm.io/npm:@org/package-a@1.0.0/helpers/index.js", // extra export path - "https://ga.jspm.io/npm:package-b@2.0.0/dist/index.js", - "https://ga.jspm.io/npm:@org/package-c@3.0.0/dist/utils/index.js", - } - - return &mockResult{ - imports: imports, - scopes: scopes, - staticDeps: staticDeps, - }, nil -} - -// mockHTTPTransport intercepts HTTP requests for testing -type mockHTTPTransport struct{} - -func (m *mockHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { - // Return mock file content for any JSPM download - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader("// mock file content")), - }, nil -} - -type mockResult struct { - imports map[string]string - scopes map[string]map[string]string - staticDeps []string -} - -func (r *mockResult) GetImports() map[string]string { - return r.imports -} - -func (r *mockResult) GetScopes() map[string]map[string]string { - return r.scopes -} - -func (r *mockResult) GetStaticDeps() []string { - return r.staticDeps -} - -func TestPackageScopedStructure(t *testing.T) { - tmpDir := t.TempDir() - os.Chdir(tmpDir) - - // Mock HTTP client to avoid real downloads - http.DefaultClient = &http.Client{ - Transport: &mockHTTPTransport{}, - } - t.Cleanup(func() { http.DefaultClient = &http.Client{} }) - - m := importmap.NewManager(".", &mockScopedGenerator{}, &mockAuditor{}) - - ctx := context.Background() - err := m.Pin(ctx, "@org/package-a") - if err != nil { - t.Fatalf("Pin failed: %v", err) - } - - // Verify main file exists with nested structure - mainFile := filepath.Join(tmpDir, "vendor/@org/package-a@1.0.0/dist/index.js") - if _, err := os.Stat(mainFile); err != nil { - t.Errorf("Main file not found: %s", mainFile) - } - - // Verify scope files exist in scopes/ subdirectory - scopeFile1 := filepath.Join(tmpDir, "vendor/@org/package-a@1.0.0/scopes/package-b@2.0.0/dist/index.js") - if _, err := os.Stat(scopeFile1); err != nil { - t.Errorf("Scope file not found: %s", scopeFile1) - } - - scopeFile2 := filepath.Join(tmpDir, "vendor/@org/package-a@1.0.0/scopes/@org/package-c@3.0.0/dist/utils/index.js") - if _, err := os.Stat(scopeFile2); err != nil { - t.Errorf("Scope file with nested path not found: %s", scopeFile2) - } - - // Verify internal files (_/) exist in package directory - internalFile := filepath.Join(tmpDir, "vendor/@org/package-a@1.0.0/_/abc123.js") - if _, err := os.Stat(internalFile); err != nil { - t.Errorf("Internal file not found: %s", internalFile) - } - - // Verify extra export paths exist in package directory - helperFile := filepath.Join(tmpDir, "vendor/@org/package-a@1.0.0/helpers/index.js") - if _, err := os.Stat(helperFile); err != nil { - t.Errorf("Helper file not found: %s", helperFile) - } - - // Verify importmap.json structure - data, err := os.ReadFile(filepath.Join(tmpDir, "importmap.json")) - if err != nil { - t.Fatalf("Failed to read importmap.json: %v", err) - } - - jsonStr := string(data) - - // Check imports - if !strings.Contains(jsonStr, `"@org/package-a": "vendor/@org/package-a@1.0.0/dist/index.js"`) { - t.Errorf("Expected import entry not found in:\n%s", jsonStr) - } - - // Check scopes - if !strings.Contains(jsonStr, `"vendor/@org/package-a@1.0.0/"`) { - t.Errorf("Expected scope key not found in:\n%s", jsonStr) - } - - if !strings.Contains(jsonStr, `"package-b": "vendor/@org/package-a@1.0.0/scopes/package-b@2.0.0/dist/index.js"`) { - t.Errorf("Expected scope value not found in:\n%s", jsonStr) - } - - // Verify internal files (_/) are NOT in importmap (not in scopes) - if strings.Contains(jsonStr, `_/abc123.js`) { - t.Errorf("Internal file should not be in importmap:\n%s", jsonStr) - } -} - -func TestUnpinDeletesDirectory(t *testing.T) { - tmpDir := t.TempDir() - os.Chdir(tmpDir) - - // Mock HTTP client to avoid real downloads - http.DefaultClient = &http.Client{ - Transport: &mockHTTPTransport{}, - } - t.Cleanup(func() { http.DefaultClient = &http.Client{} }) - - m := importmap.NewManager(".", &mockScopedGenerator{}, &mockAuditor{}) - - ctx := context.Background() - - // Pin - err := m.Pin(ctx, "@org/package-a") - if err != nil { - t.Fatalf("Pin failed: %v", err) - } - - pkgDir := filepath.Join(tmpDir, "vendor/@org/package-a@1.0.0") - if _, err := os.Stat(pkgDir); err != nil { - t.Fatalf("Package directory not created: %s", pkgDir) - } - - // Unpin - err = m.Unpin(ctx, "@org/package-a") - if err != nil { - t.Fatalf("Unpin failed: %v", err) - } - - // Verify directory deleted - if _, err := os.Stat(pkgDir); !os.IsNotExist(err) { - t.Errorf("Package directory should be deleted: %s", pkgDir) - } - - // Verify removed from importmap - data, err := os.ReadFile(filepath.Join(tmpDir, "importmap.json")) - if err != nil { - t.Fatalf("Failed to read importmap.json: %v", err) - } - - jsonStr := string(data) - if strings.Contains(jsonStr, "@org/package-a") { - t.Errorf("Package should be removed from importmap:\n%s", jsonStr) - } -} - -func TestPristineExtractsPackages(t *testing.T) { - tmpDir := t.TempDir() - os.Chdir(tmpDir) - - // Mock HTTP client to avoid real downloads - http.DefaultClient = &http.Client{ - Transport: &mockHTTPTransport{}, - } - t.Cleanup(func() { http.DefaultClient = &http.Client{} }) - - m := importmap.NewManager(".", &mockScopedGenerator{}, &mockAuditor{}) - - ctx := context.Background() - - // Pin a package to create importmap - err := m.Pin(ctx, "@org/package-a") - if err != nil { - t.Fatalf("Pin failed: %v", err) - } - - // Remove vendor directory (simulate missing files) - os.RemoveAll(filepath.Join(tmpDir, "vendor")) - - // Run Pristine - should re-download files based on importmap.json - err = m.Pristine(ctx) - if err != nil { - t.Fatalf("Pristine failed: %v", err) - } - - // Verify files re-downloaded - mainFile := filepath.Join(tmpDir, "vendor/@org/package-a@1.0.0/dist/index.js") - if _, err := os.Stat(mainFile); err != nil { - t.Errorf("Pristine should re-download main file: %s", mainFile) - } - - scopeFile := filepath.Join(tmpDir, "vendor/@org/package-a@1.0.0/scopes/package-b@2.0.0/dist/index.js") - if _, err := os.Stat(scopeFile); err != nil { - t.Errorf("Pristine should re-download scope file: %s", scopeFile) - } -} diff --git a/importmap/internal/importmap/pristine.go b/importmap/internal/importmap/pristine.go index 57b3d52..376ca86 100644 --- a/importmap/internal/importmap/pristine.go +++ b/importmap/internal/importmap/pristine.go @@ -12,12 +12,12 @@ func (m *manager) Pristine(ctx context.Context) error { var pkg []string unique := make(map[string]bool) - for _, url := range m.modules.Map { - if !strings.HasPrefix(url, "vendor/") { + for _, path := range m.modules.Map { + if !strings.HasPrefix(path, "vendor/") { continue } - match := pkgReg.FindStringSubmatch(url) + match := pkgReg.FindStringSubmatch(path) if len(match) != 2 { continue } diff --git a/importmap/internal/importmap/save.go b/importmap/internal/importmap/save.go new file mode 100644 index 0000000..8537aad --- /dev/null +++ b/importmap/internal/importmap/save.go @@ -0,0 +1,24 @@ +package importmap + +import ( + "os" + "path/filepath" +) + +// save writes the current importmap state to importmap.json +// in the container folder. +func (m *manager) save() error { + outputPath := filepath.Join(m.containerFolder, "importmap.json") + if err := os.MkdirAll(filepath.Dir(outputPath), os.ModePerm); err != nil { + return err + } + + f, err := os.Create(outputPath) + if err != nil { + return err + } + defer f.Close() + + _, err = f.Write(m.modules.Bytes()) + return err +} diff --git a/importmap/internal/importmap/unpin.go b/importmap/internal/importmap/unpin.go index 8f93685..67e2507 100644 --- a/importmap/internal/importmap/unpin.go +++ b/importmap/internal/importmap/unpin.go @@ -5,57 +5,29 @@ import ( "fmt" "os" "path/filepath" - "regexp" ) func (m *manager) Unpin(ctx context.Context, packages ...string) error { for _, pkg := range packages { - url, ok := m.modules.Map[pkg] + path, ok := m.modules.Map[pkg] if !ok { fmt.Printf("[warn] package %s not pinned\n", pkg) continue } - pkgDir, version := extractPackageDirAndVersion(url) - if pkgDir == "" { - fmt.Printf("[warn] invalid package path: %s\n", url) - continue - } + // path is "vendor/name@version/bundle.min.js" + // so the directory to remove is the parent: "vendor/name@version" + pkgDir := filepath.Dir(path) - fmt.Printf("[info] unpinning %s@%s\n", pkg, version) + fmt.Printf("[info] unpinning %s\n", pkg) - // 1. Remove from imports delete(m.modules.Map, pkg) - // 2. Remove scope for this package - // Scope key format: "vendor/@org/package@1.2.3/" - scopeKey := pkgDir + "/" - delete(m.modules.Scopes, scopeKey) - - // 3. Delete entire package directory fullPath := filepath.Join(m.containerFolder, pkgDir) if err := os.RemoveAll(fullPath); err != nil { fmt.Printf("[warn] failed to delete %s: %v\n", pkgDir, err) - } else { - fmt.Printf("[info] deleted %s\n", pkgDir) } } - return m.write() -} - -// extractPackageDirAndVersion extracts package directory and version from vendor path -// "vendor/@org/package@1.2.3/dist/index.js" → "vendor/@org/package@1.2.3", "1.2.3" -// "vendor/pkg@4.5.6/index.js" → "vendor/pkg@4.5.6", "4.5.6" -func extractPackageDirAndVersion(vendorPath string) (pkgDir, version string) { - // Match pattern: vendor/[optional @org/]package@version/... - // Capture everything up to and including @version - re := regexp.MustCompile(`^(vendor/(?:@[^/]+/)?[^/@]+@([\d\.]+))`) - matches := re.FindStringSubmatch(vendorPath) - - if len(matches) < 3 { - return "", "" - } - - return matches[1], matches[2] + return m.save() } diff --git a/importmap/internal/jspm/jspm.go b/importmap/internal/jspm/jspm.go deleted file mode 100644 index 911ee61..0000000 --- a/importmap/internal/jspm/jspm.go +++ /dev/null @@ -1,90 +0,0 @@ -package jspm - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" -) - -const baseURL = "https://api.jspm.io/generate" - -type client struct{} - -// GeneratorResult contains the imports, scopes, and staticDeps returned by JSPM. -type GeneratorResult struct { - Imports map[string]string - Scopes map[string]map[string]string - StaticDeps []string -} - -// GetImports returns the imports map. -func (r *GeneratorResult) GetImports() map[string]string { - return r.Imports -} - -// GetScopes returns the scopes map. -func (r *GeneratorResult) GetScopes() map[string]map[string]string { - return r.Scopes -} - -// GetStaticDeps returns the static dependencies list. -func (r *GeneratorResult) GetStaticDeps() []string { - return r.StaticDeps -} - -// Generate return the the import maps for the given packages. -func (c *client) Generate(ctx context.Context, packages ...string) (any, error) { - payload := map[string]any{ - "install": packages, - "env": []string{"browser", "production", "module"}, - } - - data, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("marshal error: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(data)) - if err != nil { - return nil, err - } - - res, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - - defer res.Body.Close() - - b, _ := io.ReadAll(res.Body) - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("generate response error: %s", b) - } - - var response struct { - StaticDeps []string `json:"staticDeps"` - DynamicDeps []string `json:"dynamicDeps"` - Map struct { - Imports map[string]string `json:"imports"` - Scopes map[string]map[string]string `json:"scopes"` - } `json:"map"` - } - - if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error decoding response body: %w", err) - } - - return &GeneratorResult{ - Imports: response.Map.Imports, - Scopes: response.Map.Scopes, - StaticDeps: response.StaticDeps, - }, nil -} - -func Client() *client { - return new(client) -}