Skip to content

Commit 4a600e4

Browse files
authored
feat(load)!: add opt-in network fallback for package specifiers (#12)
* feat(load): add opt-in network fallback for package specifiers When local resolution fails for npm:/jsr: specifiers, Load() can now fall back to fetching from unpkg.com CDN. This is opt-in via the new Fetcher field on load.Options — nil (default) preserves existing behavior with no network access. This enables cem to delete its own loadFromNetwork() workaround and rely entirely on load.Load() for both local and network resolution. Assisted-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: code review * fix: code review * fix: code review
1 parent 09925cd commit 4a600e4

7 files changed

Lines changed: 468 additions & 14 deletions

File tree

load/fetch.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
Copyright 2026 Benny Powers. All rights reserved.
3+
Use of this source code is governed by the GPLv3
4+
license that can be found in the LICENSE file.
5+
*/
6+
7+
package load
8+
9+
import (
10+
"context"
11+
"errors"
12+
"fmt"
13+
"io"
14+
"net/http"
15+
"time"
16+
17+
"bennypowers.dev/asimonim/internal/version"
18+
)
19+
20+
const (
21+
// DefaultTimeout is the maximum time to wait for a network fetch.
22+
DefaultTimeout = 30 * time.Second
23+
24+
// DefaultMaxSize is the maximum allowed response size (10 MB).
25+
DefaultMaxSize int64 = 10 * 1024 * 1024
26+
)
27+
28+
// Fetcher fetches content from a URL.
29+
type Fetcher interface {
30+
Fetch(ctx context.Context, url string) ([]byte, error)
31+
}
32+
33+
// HTTPFetcher fetches content over HTTP with size limiting.
34+
type HTTPFetcher struct {
35+
maxSize int64
36+
client *http.Client
37+
}
38+
39+
// NewHTTPFetcher creates an HTTPFetcher with the given maximum response size.
40+
func NewHTTPFetcher(maxSize int64) *HTTPFetcher {
41+
return &HTTPFetcher{
42+
maxSize: maxSize,
43+
client: &http.Client{},
44+
}
45+
}
46+
47+
// Fetch fetches content from the given URL.
48+
func (f *HTTPFetcher) Fetch(ctx context.Context, url string) ([]byte, error) {
49+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
50+
if err != nil {
51+
return nil, fmt.Errorf("creating request for %s: %w", url, err)
52+
}
53+
54+
req.Header.Set("User-Agent", "asimonim/"+version.Get())
55+
56+
resp, err := f.client.Do(req)
57+
if err != nil {
58+
if errors.Is(err, context.DeadlineExceeded) {
59+
return nil, fmt.Errorf("timeout fetching %s: %w", url, err)
60+
}
61+
return nil, fmt.Errorf("fetching %s: %w", url, err)
62+
}
63+
defer func() { _ = resp.Body.Close() }()
64+
65+
if resp.StatusCode != http.StatusOK {
66+
return nil, fmt.Errorf("fetching %s: %s", url, resp.Status)
67+
}
68+
69+
limitedReader := io.LimitReader(resp.Body, f.maxSize+1)
70+
content, err := io.ReadAll(limitedReader)
71+
if err != nil {
72+
return nil, fmt.Errorf("reading response from %s: %w", url, err)
73+
}
74+
75+
if int64(len(content)) > f.maxSize {
76+
return nil, fmt.Errorf("response from %s exceeds maximum size of %d bytes", url, f.maxSize)
77+
}
78+
79+
return content, nil
80+
}

load/fetch_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
Copyright 2026 Benny Powers. All rights reserved.
3+
Use of this source code is governed by the GPLv3
4+
license that can be found in the LICENSE file.
5+
*/
6+
7+
package load
8+
9+
import (
10+
"context"
11+
"net/http"
12+
"net/http/httptest"
13+
"strings"
14+
"testing"
15+
"time"
16+
)
17+
18+
func TestHTTPFetcher_Success(t *testing.T) {
19+
body := `{"color": {"$value": "#fff", "$type": "color"}}`
20+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+
w.Header().Set("Content-Type", "application/json")
22+
_, _ = w.Write([]byte(body))
23+
}))
24+
defer srv.Close()
25+
26+
f := NewHTTPFetcher(DefaultMaxSize)
27+
content, err := f.Fetch(context.Background(), srv.URL+"/tokens.json")
28+
if err != nil {
29+
t.Fatalf("Fetch() error = %v", err)
30+
}
31+
if string(content) != body {
32+
t.Errorf("Fetch() = %q, want %q", string(content), body)
33+
}
34+
}
35+
36+
func TestHTTPFetcher_Timeout(t *testing.T) {
37+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+
time.Sleep(200 * time.Millisecond)
39+
_, _ = w.Write([]byte("too late"))
40+
}))
41+
defer srv.Close()
42+
43+
f := NewHTTPFetcher(DefaultMaxSize)
44+
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
45+
defer cancel()
46+
47+
_, err := f.Fetch(ctx, srv.URL+"/tokens.json")
48+
if err == nil {
49+
t.Fatal("expected timeout error")
50+
}
51+
if !strings.Contains(err.Error(), "timeout") && !strings.Contains(err.Error(), "context deadline exceeded") {
52+
t.Errorf("expected timeout error, got: %v", err)
53+
}
54+
}
55+
56+
func TestHTTPFetcher_MaxSizeExceeded(t *testing.T) {
57+
// Response is 100 bytes, limit to 50
58+
body := strings.Repeat("x", 100)
59+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+
_, _ = w.Write([]byte(body))
61+
}))
62+
defer srv.Close()
63+
64+
f := NewHTTPFetcher(50)
65+
_, err := f.Fetch(context.Background(), srv.URL+"/tokens.json")
66+
if err == nil {
67+
t.Fatal("expected max size error")
68+
}
69+
if !strings.Contains(err.Error(), "exceeds maximum size") {
70+
t.Errorf("expected max size error, got: %v", err)
71+
}
72+
}
73+
74+
func TestHTTPFetcher_Non200Status(t *testing.T) {
75+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+
http.Error(w, "not found", http.StatusNotFound)
77+
}))
78+
defer srv.Close()
79+
80+
f := NewHTTPFetcher(DefaultMaxSize)
81+
_, err := f.Fetch(context.Background(), srv.URL+"/tokens.json")
82+
if err == nil {
83+
t.Fatal("expected error for 404")
84+
}
85+
if !strings.Contains(err.Error(), "404") {
86+
t.Errorf("expected 404 in error, got: %v", err)
87+
}
88+
}

load/load.go

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ license that can be found in the LICENSE file.
88
package load
99

1010
import (
11+
"context"
12+
"errors"
1113
"fmt"
1214
"path/filepath"
15+
"time"
1316

1417
"bennypowers.dev/asimonim/config"
1518
"bennypowers.dev/asimonim/fs"
@@ -20,6 +23,14 @@ import (
2023
"bennypowers.dev/asimonim/token"
2124
)
2225

26+
var (
27+
// ErrLocalResolution indicates that local filesystem resolution failed.
28+
ErrLocalResolution = errors.New("local resolution failed")
29+
30+
// ErrNetworkFallback indicates that the CDN network fallback also failed.
31+
ErrNetworkFallback = errors.New("network fallback failed")
32+
)
33+
2334
// Options configures how tokens are loaded.
2435
type Options struct {
2536
// Root is the directory for local specifier resolution (required for local/npm: paths).
@@ -39,6 +50,16 @@ type Options struct {
3950
// SchemaVersion overrides auto-detection from file content.
4051
// Takes precedence over config file if set.
4152
SchemaVersion schema.Version
53+
54+
// Fetcher enables opt-in network fallback for npm: specifiers.
55+
// When set, if local resolution fails for an npm: specifier,
56+
// Load will attempt to fetch the content from unpkg.com.
57+
// Nil means no network fallback (default).
58+
Fetcher Fetcher
59+
60+
// FetchTimeout is the maximum time to wait for a network fetch.
61+
// Defaults to DefaultTimeout when zero. Has no effect if Fetcher is nil.
62+
FetchTimeout time.Duration
4263
}
4364

4465
// Load loads design tokens from a specifier with full resolution.
@@ -48,16 +69,19 @@ type Options struct {
4869
// - npm package: "npm:@scope/pkg/tokens.json" (requires node_modules)
4970
// - jsr package: "jsr:@scope/pkg/tokens.json" (requires node_modules)
5071
//
72+
// When Options.Fetcher is set, npm: specifiers that fail local resolution
73+
// will fall back to fetching from unpkg.com.
74+
//
5175
// The loading process:
5276
// 1. Optionally loads config from .config/design-tokens.yaml
5377
// 2. Applies Options values (they take precedence over config)
54-
// 3. Resolves specifier to file content via filesystem
78+
// 3. Resolves specifier to file content via filesystem (with optional CDN fallback)
5579
// 4. Detects schema version (if not specified)
5680
// 5. Parses tokens
5781
// 6. Resolves $extends (v2025.10)
5882
// 7. Resolves aliases
5983
// 8. Returns *token.Map
60-
func Load(spec string, opts Options) (*token.Map, error) {
84+
func Load(ctx context.Context, spec string, opts Options) (*token.Map, error) {
6185
// Set up filesystem
6286
filesystem := opts.FS
6387
if filesystem == nil {
@@ -97,7 +121,11 @@ func Load(spec string, opts Options) (*token.Map, error) {
97121
}
98122

99123
// Resolve specifier to content
100-
content, err := resolveContent(spec, root, filesystem)
124+
fetchTimeout := opts.FetchTimeout
125+
if fetchTimeout == 0 {
126+
fetchTimeout = DefaultTimeout
127+
}
128+
content, err := resolveContent(ctx, spec, root, filesystem, opts.Fetcher, fetchTimeout)
101129
if err != nil {
102130
return nil, fmt.Errorf("failed to resolve specifier %q: %w", spec, err)
103131
}
@@ -136,8 +164,10 @@ func Load(spec string, opts Options) (*token.Map, error) {
136164
return token.NewMap(tokens, prefix), nil
137165
}
138166

139-
// resolveContent resolves a specifier to file content via filesystem.
140-
func resolveContent(spec, root string, filesystem fs.FileSystem) ([]byte, error) {
167+
// resolveContent resolves a specifier to file content.
168+
// Tries local resolution first. If that fails and a Fetcher is provided,
169+
// falls back to CDN for npm: specifiers.
170+
func resolveContent(ctx context.Context, spec, root string, filesystem fs.FileSystem, fetcher Fetcher, fetchTimeout time.Duration) ([]byte, error) {
141171
// Create resolver chain
142172
res, err := specifier.NewDefaultResolver(filesystem, root)
143173
if err != nil {
@@ -147,7 +177,8 @@ func resolveContent(spec, root string, filesystem fs.FileSystem) ([]byte, error)
147177
// Resolve specifier to path
148178
resolved, err := res.Resolve(spec)
149179
if err != nil {
150-
return nil, err
180+
// Local resolution failed — try CDN fallback
181+
return fetchFromCDN(ctx, spec, fetcher, fetchTimeout, err)
151182
}
152183

153184
// Make local paths absolute relative to root
@@ -157,9 +188,36 @@ func resolveContent(spec, root string, filesystem fs.FileSystem) ([]byte, error)
157188
}
158189

159190
// Read file content
160-
content, err := filesystem.ReadFile(path)
161-
if err != nil {
162-
return nil, fmt.Errorf("failed to read %s: %w", path, err)
191+
content, readErr := filesystem.ReadFile(path)
192+
if readErr != nil {
193+
// File read failed — try CDN fallback (npm: specifiers only;
194+
// non-npm specifiers return localErr unchanged via CDNURL check)
195+
localErr := fmt.Errorf("failed to read %s: %w", path, readErr)
196+
return fetchFromCDN(ctx, spec, fetcher, fetchTimeout, localErr)
197+
}
198+
199+
return content, nil
200+
}
201+
202+
// fetchFromCDN attempts to fetch content from CDN as a fallback.
203+
// Returns the original localErr if no fetcher is provided or the specifier
204+
// has no CDN URL.
205+
func fetchFromCDN(ctx context.Context, spec string, fetcher Fetcher, fetchTimeout time.Duration, localErr error) ([]byte, error) {
206+
if fetcher == nil {
207+
return nil, localErr
208+
}
209+
210+
cdnURL, ok := specifier.CDNURL(spec)
211+
if !ok {
212+
return nil, localErr
213+
}
214+
215+
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
216+
defer cancel()
217+
218+
content, fetchErr := fetcher.Fetch(ctx, cdnURL)
219+
if fetchErr != nil {
220+
return nil, fmt.Errorf("%w (%w), %w: %w", ErrLocalResolution, localErr, ErrNetworkFallback, fetchErr)
163221
}
164222

165223
return content, nil

0 commit comments

Comments
 (0)