Files
kjol/bundler/js.go

199 lines
7.2 KiB
Go

package bundler
import (
"encoding/json"
"fmt"
"kjol/appenv"
"os"
"path/filepath"
"strconv"
"strings"
esbuild "github.com/evanw/esbuild/pkg/api"
)
// esbuildDefine returns the compile-time constants substituted into every JS
// bundle — both the production bundles here and the dev HMR per-module
// transforms (hmr_server.go). __ENV_TYPE__ carries the Go compile-time
// deployment environment (appenv.Environment) into the JS, where
// frontend/src/env.ts reads it. The build-time SSR render deliberately does NOT
// define it (see internal/bundler/ssr.go), so the env badge renders only after
// the client takeover.
func esbuildDefine() map[string]string {
return map[string]string{
"__ENV_TYPE__": strconv.Quote(appenv.Environment),
}
}
// resolveEntryPoint returns the path (relative to frontendDir) of the
// SPA entry point, preferring app.ts over app.js.
func resolveEntryPoint() string {
if _, err := os.Stat(filepath.Join(frontendDir, "src/app.ts")); err == nil {
return "src/app.ts"
}
return "src/app.js"
}
// bundleJS bundles the SPA entry (app.ts/app.js) into bundle.min.js.
func bundleJS() (bundleStats, error) {
return bundleJSEntry(filepath.Join(frontendDir, resolveEntryPoint()), "bundle.min.js")
}
// bundlePublicJS bundles the public-page takeover entry into
// public.bundle.min.js. This is the client half of the SSR'd public pages:
// it re-renders the Solid tree the server emitted (re-render takeover).
func bundlePublicJS() (bundleStats, error) {
return bundleJSEntry(filepath.Join(frontendDir, "src/public.tsx"), "public.bundle.min.js")
}
// bundleJSEntry runs esbuild with the default-export shim plugin for one entry
// point. Vendor modules (anything not a relative import) are kept external so
// the import map resolves them at runtime. .tsx/.jsx files are Solid-compiled by
// the Go-native compiler (Plugin); the SPA entry is solid-js/html tagged
// templates (no JSX) and is unaffected.
func bundleJSEntry(entry, outName string) (bundleStats, error) {
// Keep `debugger` statements in development so they can be hit when
// DevTools is attached; strip them in staging/production builds.
var drop esbuild.Drop
if appenv.Environment != appenv.EnvTypeDevelopment {
drop = esbuild.DropDebugger
}
vendorDir := filepath.Join(frontendDir, "vendor")
// Every bundled vendored package's entrypoint is declared in vendor.json rather
// than discovered from each package's `exports` — those mis-resolve (e.g.
// solid-js's bare core to its SSR build, dist/server.js, where createEffect/
// onMount are no-ops, so the DOM renders but every effect is silently dead).
entrypoints, err := loadVendorManifest(vendorDir)
if err != nil {
return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
}
// The Go-native Solid compiler (Plugin) sits between the export shim and the
// vendor resolvers so it compiles .tsx/.jsx before they're resolved.
plugins := []esbuild.Plugin{defaultExportShimPlugin(), Plugin()}
plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(vendorDir, entrypoints))
result := esbuild.Build(esbuild.BuildOptions{
EntryPoints: []string{entry},
Outfile: filepath.Join(outputDir, outName),
Bundle: true,
Write: true,
Format: esbuild.FormatESModule,
Target: esbuild.ES2022,
Sourcemap: esbuild.SourceMapLinked,
SourceRoot: "./",
Outbase: "frontend",
SourcesContent: esbuild.SourcesContentInclude,
MinifyWhitespace: true,
MinifyIdentifiers: true,
MinifySyntax: true,
Drop: drop,
Define: esbuildDefine(),
// Vendored packages live under frontend/vendor/<pkg> (npm-pack layout).
// NodePaths makes esbuild resolve bare imports there and bundle + tree-shake
// them like source (no node_modules). Bundling solid-js from a single vendored
// copy — rather than leaving it external for the import map — keeps ONE reactive
// runtime instance; a split instance silently breaks effect flushing (onMount
// never fires). Browser + development conditions pick each package's DOM dev
// build over any SSR entry its `module` field points at. Packages whose default
// entry pulls an un-bundled dep tree point their own package.json at a
// self-contained dist (e.g. pdf-lib's `browser` field) — config lives with the
// package, not here. vendorResolvePlugin marks bare imports NOT under
// frontend/vendor as external (import-map resolved), so the vendor set stays
// data-driven with no per-package list in this source.
Platform: esbuild.PlatformBrowser,
Conditions: []string{"development"},
NodePaths: []string{vendorDir},
// Assets emitted by assetURLPlugin (e.g. the pdfjs worker, which can't be
// inlined because it reads import.meta.url) land in wwwroot/vendor and are
// referenced by a root-absolute URL.
AssetNames: "vendor/[name]",
PublicPath: "/",
LogLevel: esbuild.LogLevelWarning,
Plugins: plugins,
})
if len(result.Errors) > 0 {
for _, e := range result.Errors {
loc := ""
if e.Location != nil {
loc = fmt.Sprintf("%s:%d:%d ", e.Location.File, e.Location.Line, e.Location.Column)
}
fmt.Fprintf(os.Stderr, " %s%s\n", loc, e.Text)
}
return bundleStats{}, fmt.Errorf("esbuild produced %d error(s)", len(result.Errors))
}
jsPath := filepath.Join(outputDir, outName)
if err := stripSourcemapParentPrefix(jsPath + ".map"); err != nil {
return bundleStats{}, fmt.Errorf("rewriting sourcemap paths: %w", err)
}
info, err := os.Stat(jsPath)
if err != nil {
return bundleStats{}, err
}
return bundleStats{files: countSources(result), bytes: int(info.Size())}, nil
}
// stripSourcemapParentPrefix rewrites the sourcemap's `sources` entries to
// drop leading `../` segments. esbuild writes paths relative to the output
// file's directory; since the bundle lives in wwwroot/ and the sources live
// in frontend/, every entry starts with `../frontend/`. Stripping the prefix
// yields project-rooted paths like `frontend/src/app.ts`.
func stripSourcemapParentPrefix(mapPath string) error {
data, err := os.ReadFile(mapPath)
if err != nil {
return err
}
var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err != nil {
return err
}
raw, ok := m["sources"]
if !ok {
return nil
}
var sources []string
if err := json.Unmarshal(raw, &sources); err != nil {
return err
}
for i, s := range sources {
for strings.HasPrefix(s, "../") {
s = s[3:]
}
sources[i] = s
}
newRaw, err := json.Marshal(sources)
if err != nil {
return err
}
m["sources"] = newRaw
out, err := json.Marshal(m)
if err != nil {
return err
}
return os.WriteFile(mapPath, out, 0644)
}
// countSources returns the number of input files that contributed to
// the bundle, derived from esbuild's metafile. When the metafile is
// not requested, falls back to the entry-count.
func countSources(result esbuild.BuildResult) int {
if result.Metafile == "" {
return 1
}
// Each " \"path\":" line in the inputs section counts as a file.
// Cheap heuristic — avoids pulling in encoding/json for a stat.
idx := strings.Index(result.Metafile, `"inputs":{`)
if idx < 0 {
return 1
}
end := strings.Index(result.Metafile[idx:], `},"outputs"`)
if end < 0 {
return 1
}
return strings.Count(result.Metafile[idx:idx+end], `":{`)
}