203 lines
7.4 KiB
Go
203 lines
7.4 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
|
|
}
|
|
|
|
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/<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: 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], `":{`)
|
|
}
|