package webbundler 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 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 } vdirs := vendorDirs() // 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). // Manifests from every vendor dir are merged (kjol runtime wins) so the shared // solid-js resolves to ONE copy across the app + kit trees. entrypoints, err := loadVendorManifest(vdirs) if err != nil { return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err) } // aliasPlugin resolves @ui/@kjol/@appgen into the kjol web tree (and the app's // generated dir) first. 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{aliasPlugin(), defaultExportShimPlugin(), Plugin()} plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(entrypoints, vdirs)) 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: frontendDir, SourcesContent: esbuild.SourcesContentInclude, MinifyWhitespace: true, MinifyIdentifiers: true, MinifySyntax: true, Drop: drop, Define: esbuildDefine(), // Vendored packages live under frontend/vendor/ (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: vdirs, // 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], `":{`) }