Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions importmap/internal/bundlejs/bundlejs.go
Original file line number Diff line number Diff line change
@@ -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)
}
195 changes: 195 additions & 0 deletions importmap/internal/bundlejs/bundlejs_test.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions importmap/internal/importmap/bundler.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 2 additions & 2 deletions importmap/internal/importmap/importmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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":
Expand Down
Loading
Loading