Files
kjol/go/bundler/hmr_vendor.go

117 lines
4.0 KiB
Go

//go:build dev
package bundler
// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,
// wired up by an import map the SPA shell inlines. Because each specifier maps to
// exactly one URL, and every vendor bundle re-externalizes the OTHER vendored
// specifiers (rather than inlining them), solid-js stays a single runtime
// instance across the whole app — the invariant the prod bundler also guards.
import (
"encoding/json"
"fmt"
"net/http"
"strings"
esbuild "github.com/evanw/esbuild/pkg/api"
)
const vendorURLPrefix = "/@vendor/"
// buildImportMap returns the JSON `{"imports": {...}}` body the SPA shell inlines
// in <script type="importmap">, mapping every vendored bare specifier to its
// /@vendor/ URL.
func (d *devServer) buildImportMap() string {
imports := make(map[string]string, len(d.vendorEntrypoints))
for spec := range d.vendorEntrypoints {
imports[spec] = vendorURLPrefix + spec + ".js"
}
body, err := json.MarshalIndent(map[string]any{"imports": imports}, "", " ")
if err != nil {
return `{"imports":{}}`
}
return string(body)
}
// serveVendor serves a pre-bundled vendored package (lazily built + cached).
func (d *devServer) serveVendor(w http.ResponseWriter, r *http.Request) {
spec := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, vendorURLPrefix), ".js")
relFile, ok := d.vendorEntrypoints[spec]
if !ok {
http.NotFound(w, r)
return
}
d.vendorMu.Lock()
bundled, cached := d.vendorCache[r.URL.Path]
d.vendorMu.Unlock()
if !cached {
b, err := d.bundleVendor(relFile)
if err != nil {
http.Error(w, "vendor bundle failed: "+err.Error(), http.StatusInternalServerError)
return
}
d.vendorMu.Lock()
d.vendorCache[r.URL.Path] = b
d.vendorMu.Unlock()
bundled = b
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
// Vendor rarely changes; let the browser cache within a session.
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(bundled)
}
// bundleVendor bundles one vendored package entrypoint into a single ESM file,
// re-externalizing the OTHER vendored specifiers so they resolve (once) through
// the import map. Mirrors the prod vendor resolution (NodePaths + development
// condition) so the dev copy matches the shipped one.
func (d *devServer) bundleVendor(absFile string) ([]byte, error) {
result := esbuild.Build(esbuild.BuildOptions{
EntryPoints: []string{absFile},
Bundle: true,
Write: false,
Format: esbuild.FormatESModule,
Target: esbuild.ES2022,
Platform: esbuild.PlatformBrowser,
Conditions: []string{"development"},
NodePaths: d.vendorDirs,
Sourcemap: esbuild.SourceMapInline,
LogLevel: esbuild.LogLevelSilent,
Plugins: []esbuild.Plugin{d.vendorSharedExternalPlugin()},
})
if len(result.Errors) > 0 {
msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{})
return nil, fmt.Errorf("%s", strings.Join(msgs, "\n"))
}
if len(result.OutputFiles) == 0 {
return nil, fmt.Errorf("no output")
}
return result.OutputFiles[0].Contents, nil
}
// vendorSharedExternalPlugin marks a bare import external ONLY when it's an exact
// vendored entrypoint (thus resolvable via the import map). That keeps shared
// singletons — above all solid-js — as one instance across every vendor bundle,
// while deep subpaths and non-vendored transitive deps get bundled in.
func (d *devServer) vendorSharedExternalPlugin() esbuild.Plugin {
return esbuild.Plugin{
Name: "dev-vendor-shared-external",
Setup: func(b esbuild.PluginBuild) {
b.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
if a.Kind == esbuild.ResolveEntryPoint {
return esbuild.OnResolveResult{}, nil
}
if _, ok := d.vendorEntrypoints[a.Path]; ok {
return esbuild.OnResolveResult{Path: a.Path, External: true}, nil
}
return esbuild.OnResolveResult{}, nil // bundle via NodePaths
})
},
}
}