Skip to content

Commit a79a8a2

Browse files
authored
fix(cdn): exclude packages from CDN resolver including transitive deps (#36)
* fix(cdn): exclude packages from CDN resolver including transitive deps The CDN resolver lacked exclude support entirely. When using generate() with the exclude option, excluded packages still appeared in the output import map if they were transitive dependencies of non-excluded packages. Add WithExclude builder to CDN resolver, filtering excluded packages from both direct dependencies in ResolvePackageJSON and transitive dependencies in resolvePackage. Wire exclude through WASM generate() and update TypeScript GenerateOptions type. Also fix a pre-existing race condition in PackageCache.GetOrLoad where the fast path could read entry.pkg/entry.err while another goroutine's sync.Once loader was still running. Store the loader function in the cache entry so any goroutine calling entry.load() uses the correct loader via sync.Once synchronization. Closes #32 Assisted-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use prefix checks in exclude assertions, fix eviction ordering Use strings.HasPrefix for lib-b exclusion checks instead of exact key matching, so any subpath is caught. Move eviction before insertion in GetOrLoad to match Set() ordering and prevent panic on empty c.order with small maxSize. Change >= to == since the check now runs before the new entry is added. Assisted-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1954571 commit a79a8a2

9 files changed

Lines changed: 276 additions & 100 deletions

File tree

cdn/cache.go

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,19 @@ type PackageCache struct {
3333
}
3434

3535
type cacheEntry struct {
36-
pkg *packagejson.PackageJSON
37-
once sync.Once
38-
err error
36+
pkg *packagejson.PackageJSON
37+
once sync.Once
38+
err error
39+
loader func() (*packagejson.PackageJSON, error)
40+
}
41+
42+
func (e *cacheEntry) load() {
43+
e.once.Do(func() {
44+
if e.loader != nil {
45+
e.pkg, e.err = e.loader()
46+
e.loader = nil
47+
}
48+
})
3949
}
4050

4151
// NewPackageCache creates a new package cache with the specified maximum size.
@@ -90,7 +100,7 @@ func (c *PackageCache) Set(pkgName, version string, pkg *packagejson.PackageJSON
90100
}
91101

92102
// Evict oldest if at capacity
93-
if len(c.entries) >= c.maxSize {
103+
if len(c.entries) == c.maxSize {
94104
oldest := c.order[0]
95105
c.order = c.order[1:]
96106
delete(c.entries, oldest)
@@ -110,11 +120,9 @@ func (c *PackageCache) GetOrLoad(pkgName, version string, loader func() (*packag
110120
entry, ok := c.entries[key]
111121
c.mu.RUnlock()
112122

113-
if ok && entry.pkg != nil {
114-
return entry.pkg, nil
115-
}
116-
if ok && entry.err != nil {
117-
return nil, entry.err
123+
if ok {
124+
entry.load()
125+
return entry.pkg, entry.err
118126
}
119127

120128
// Slow path: create entry and load
@@ -123,35 +131,25 @@ func (c *PackageCache) GetOrLoad(pkgName, version string, loader func() (*packag
123131
entry, ok = c.entries[key]
124132
if ok {
125133
c.mu.Unlock()
126-
entry.once.Do(func() {})
127-
if entry.err != nil {
128-
return nil, entry.err
129-
}
130-
return entry.pkg, nil
134+
entry.load()
135+
return entry.pkg, entry.err
131136
}
132137

133-
// Create new entry
134-
entry = &cacheEntry{}
135-
c.entries[key] = entry
136-
137138
// Evict oldest if at capacity
138-
if len(c.entries) >= c.maxSize {
139+
if len(c.entries) == c.maxSize {
139140
oldest := c.order[0]
140141
c.order = c.order[1:]
141142
delete(c.entries, oldest)
142143
}
144+
145+
// Create new entry with loader
146+
entry = &cacheEntry{loader: loader}
147+
c.entries[key] = entry
143148
c.order = append(c.order, key)
144149
c.mu.Unlock()
145150

146-
// Load outside the lock
147-
entry.once.Do(func() {
148-
entry.pkg, entry.err = loader()
149-
})
150-
151-
if entry.err != nil {
152-
return nil, entry.err
153-
}
154-
return entry.pkg, nil
151+
entry.load()
152+
return entry.pkg, entry.err
155153
}
156154

157155
// Invalidate removes a specific package from the cache.

npm/src/mappa.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface GenerateOptions {
1111
template?: string;
1212
/** Export conditions to resolve */
1313
conditions?: string[];
14+
/** Packages to exclude from the generated map, including as transitive dependencies */
15+
exclude?: string[];
1416
}
1517

1618
export interface ResolveOptions {

resolve/cdn/cdn.go

Lines changed: 106 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package cdn
2121
import (
2222
"context"
2323
"maps"
24+
"slices"
2425
"strings"
2526
"sync"
2627

@@ -40,8 +41,9 @@ type Resolver struct {
4041
logger resolve.Logger
4142
conditions []string
4243
includeDev bool
43-
maxDepth int // Maximum dependency depth (0 = unlimited)
44-
resolveScope bool // Whether to resolve transitive dependencies as scopes
44+
excludePackages []string
45+
maxDepth int // Maximum dependency depth (0 = unlimited)
46+
resolveScope bool // Whether to resolve transitive dependencies as scopes
4547
}
4648

4749
// New creates a new CDN resolver with default settings.
@@ -61,16 +63,17 @@ func New(fetcher mappacdn.Fetcher) *Resolver {
6163
func (r *Resolver) WithProvider(provider mappacdn.Provider) *Resolver {
6264
tmpl, _ := resolve.ParseTemplate(provider.ModuleTemplate)
6365
return &Resolver{
64-
fetcher: r.fetcher,
65-
provider: provider,
66-
registry: r.registry,
67-
template: tmpl,
68-
cache: r.cache,
69-
logger: r.logger,
70-
conditions: r.conditions,
71-
includeDev: r.includeDev,
72-
maxDepth: r.maxDepth,
73-
resolveScope: r.resolveScope,
66+
fetcher: r.fetcher,
67+
provider: provider,
68+
registry: r.registry,
69+
template: tmpl,
70+
cache: r.cache,
71+
logger: r.logger,
72+
conditions: r.conditions,
73+
includeDev: r.includeDev,
74+
excludePackages: r.excludePackages,
75+
maxDepth: r.maxDepth,
76+
resolveScope: r.resolveScope,
7477
}
7578
}
7679

@@ -81,97 +84,121 @@ func (r *Resolver) WithTemplate(pattern string) (*Resolver, error) {
8184
return nil, err
8285
}
8386
return &Resolver{
84-
fetcher: r.fetcher,
85-
provider: r.provider,
86-
registry: r.registry,
87-
template: tmpl,
88-
cache: r.cache,
89-
logger: r.logger,
90-
conditions: r.conditions,
91-
includeDev: r.includeDev,
92-
maxDepth: r.maxDepth,
93-
resolveScope: r.resolveScope,
87+
fetcher: r.fetcher,
88+
provider: r.provider,
89+
registry: r.registry,
90+
template: tmpl,
91+
cache: r.cache,
92+
logger: r.logger,
93+
conditions: r.conditions,
94+
includeDev: r.includeDev,
95+
excludePackages: r.excludePackages,
96+
maxDepth: r.maxDepth,
97+
resolveScope: r.resolveScope,
9498
}, nil
9599
}
96100

97101
// WithLogger returns a new Resolver with the specified logger.
98102
func (r *Resolver) WithLogger(logger resolve.Logger) *Resolver {
99103
return &Resolver{
100-
fetcher: r.fetcher,
101-
provider: r.provider,
102-
registry: r.registry,
103-
template: r.template,
104-
cache: r.cache,
105-
logger: logger,
106-
conditions: r.conditions,
107-
includeDev: r.includeDev,
108-
maxDepth: r.maxDepth,
109-
resolveScope: r.resolveScope,
104+
fetcher: r.fetcher,
105+
provider: r.provider,
106+
registry: r.registry,
107+
template: r.template,
108+
cache: r.cache,
109+
logger: logger,
110+
conditions: r.conditions,
111+
includeDev: r.includeDev,
112+
excludePackages: r.excludePackages,
113+
maxDepth: r.maxDepth,
114+
resolveScope: r.resolveScope,
110115
}
111116
}
112117

113118
// WithConditions returns a new Resolver with the specified export conditions.
114119
func (r *Resolver) WithConditions(conditions []string) *Resolver {
115120
return &Resolver{
116-
fetcher: r.fetcher,
117-
provider: r.provider,
118-
registry: r.registry,
119-
template: r.template,
120-
cache: r.cache,
121-
logger: r.logger,
122-
conditions: conditions,
123-
includeDev: r.includeDev,
124-
maxDepth: r.maxDepth,
125-
resolveScope: r.resolveScope,
121+
fetcher: r.fetcher,
122+
provider: r.provider,
123+
registry: r.registry,
124+
template: r.template,
125+
cache: r.cache,
126+
logger: r.logger,
127+
conditions: conditions,
128+
includeDev: r.includeDev,
129+
excludePackages: r.excludePackages,
130+
maxDepth: r.maxDepth,
131+
resolveScope: r.resolveScope,
126132
}
127133
}
128134

129135
// WithIncludeDev returns a new Resolver that includes devDependencies.
130136
func (r *Resolver) WithIncludeDev(include bool) *Resolver {
131137
return &Resolver{
132-
fetcher: r.fetcher,
133-
provider: r.provider,
134-
registry: r.registry,
135-
template: r.template,
136-
cache: r.cache,
137-
logger: r.logger,
138-
conditions: r.conditions,
139-
includeDev: include,
140-
maxDepth: r.maxDepth,
141-
resolveScope: r.resolveScope,
138+
fetcher: r.fetcher,
139+
provider: r.provider,
140+
registry: r.registry,
141+
template: r.template,
142+
cache: r.cache,
143+
logger: r.logger,
144+
conditions: r.conditions,
145+
includeDev: include,
146+
excludePackages: r.excludePackages,
147+
maxDepth: r.maxDepth,
148+
resolveScope: r.resolveScope,
142149
}
143150
}
144151

145152
// WithMaxDepth returns a new Resolver with a maximum dependency depth.
146153
// 0 means unlimited (default), 1 means direct dependencies only.
147154
func (r *Resolver) WithMaxDepth(depth int) *Resolver {
148155
return &Resolver{
149-
fetcher: r.fetcher,
150-
provider: r.provider,
151-
registry: r.registry,
152-
template: r.template,
153-
cache: r.cache,
154-
logger: r.logger,
155-
conditions: r.conditions,
156-
includeDev: r.includeDev,
157-
maxDepth: depth,
158-
resolveScope: r.resolveScope,
156+
fetcher: r.fetcher,
157+
provider: r.provider,
158+
registry: r.registry,
159+
template: r.template,
160+
cache: r.cache,
161+
logger: r.logger,
162+
conditions: r.conditions,
163+
includeDev: r.includeDev,
164+
excludePackages: r.excludePackages,
165+
maxDepth: depth,
166+
resolveScope: r.resolveScope,
159167
}
160168
}
161169

162170
// WithResolveScope controls whether to generate scopes for transitive dependencies.
163171
func (r *Resolver) WithResolveScope(resolveScope bool) *Resolver {
164172
return &Resolver{
165-
fetcher: r.fetcher,
166-
provider: r.provider,
167-
registry: r.registry,
168-
template: r.template,
169-
cache: r.cache,
170-
logger: r.logger,
171-
conditions: r.conditions,
172-
includeDev: r.includeDev,
173-
maxDepth: r.maxDepth,
174-
resolveScope: resolveScope,
173+
fetcher: r.fetcher,
174+
provider: r.provider,
175+
registry: r.registry,
176+
template: r.template,
177+
cache: r.cache,
178+
logger: r.logger,
179+
conditions: r.conditions,
180+
includeDev: r.includeDev,
181+
excludePackages: r.excludePackages,
182+
maxDepth: r.maxDepth,
183+
resolveScope: resolveScope,
184+
}
185+
}
186+
187+
// WithExclude returns a new Resolver that excludes the specified packages
188+
// from the generated import map, including as transitive dependencies.
189+
func (r *Resolver) WithExclude(packages []string) *Resolver {
190+
return &Resolver{
191+
fetcher: r.fetcher,
192+
provider: r.provider,
193+
registry: r.registry,
194+
template: r.template,
195+
cache: r.cache,
196+
logger: r.logger,
197+
conditions: r.conditions,
198+
includeDev: r.includeDev,
199+
excludePackages: packages,
200+
maxDepth: r.maxDepth,
201+
resolveScope: r.resolveScope,
175202
}
176203
}
177204

@@ -203,6 +230,10 @@ func (r *Resolver) ResolvePackageJSON(ctx context.Context, pkg *packagejson.Pack
203230
}
204231
}
205232

233+
for _, pkg := range r.excludePackages {
234+
delete(deps, pkg)
235+
}
236+
206237
// Resolve each dependency
207238
var wg sync.WaitGroup
208239
var mu sync.Mutex
@@ -281,6 +312,9 @@ func (r *Resolver) resolvePackage(
281312
sem := make(chan struct{}, 10)
282313

283314
for depName, depVer := range pkg.Dependencies {
315+
if slices.Contains(r.excludePackages, depName) {
316+
continue
317+
}
284318
wg.Add(1)
285319
go func(name, ver string) {
286320
defer wg.Done()

0 commit comments

Comments
 (0)