bundler.go
package esbuild
import (
"cmp"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/evanw/esbuild/pkg/api"
)
// Bundler compiles frontend components using esbuild.
type Bundler struct {
config *Config
outputDir string
devMode bool
}
// NewBundler creates a bundler with the given configuration.
func NewBundler(cfg *Config, outputDir string, devMode bool) *Bundler {
return &Bundler{
config: cfg,
outputDir: outputDir,
devMode: devMode,
}
}
// Config returns the bundler configuration.
func (b *Bundler) Config() *Config {
return b.config
}
// Build compiles the components bundle.
// If a build.mjs file exists, it uses Node.js to run it (for plugin support).
// Otherwise, it uses esbuild's Go API directly.
func (b *Bundler) Build() error {
// Check for Node.js build script (supports plugins like esbuild-plugin-solid)
if _, err := os.Stat("build.mjs"); err == nil {
return b.buildWithNode()
}
return b.buildWithGoAPI()
}
// buildWithNode runs the build.mjs script using Node.js.
func (b *Bundler) buildWithNode() error {
// Create output directory
if err := os.MkdirAll(b.outputDir, 0755); err != nil {
return fmt.Errorf("creating output dir: %w", err)
}
env := os.Environ()
if b.devMode {
env = append(env, "NODE_ENV=development")
} else {
env = append(env, "NODE_ENV=production")
}
cmd := exec.Command("node", "build.mjs")
cmd.Env = env
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("node build.mjs failed: %w", err)
}
return nil
}
// buildWithGoAPI uses esbuild's Go API directly (no plugin support).
func (b *Bundler) buildWithGoAPI() error {
// Get entry path (default to components/index.ts)
entryPath := cmp.Or(b.config.Entry, "components/index.ts")
// Check if entry file exists
if _, err := os.Stat(entryPath); os.IsNotExist(err) {
// No entry found, create empty bundle
if err := os.MkdirAll(b.outputDir, 0755); err != nil {
return fmt.Errorf("creating output dir: %w", err)
}
return os.WriteFile(filepath.Join(b.outputDir, "components.js"), []byte("// No components\nwindow.__render = () => {};\nwindow.__unmount = () => {};\n"), 0644)
}
// Generate virtual entry that wraps user exports
virtualEntry := generateVirtualEntry(entryPath)
// Create output directory
if err := os.MkdirAll(b.outputDir, 0755); err != nil {
return fmt.Errorf("creating output dir: %w", err)
}
// Get absolute working directory for node_modules resolution
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting working directory: %w", err)
}
// Build options
opts := api.BuildOptions{
Stdin: &api.StdinOptions{
Contents: virtualEntry,
ResolveDir: wd,
Sourcefile: "_virtual_entry.ts",
Loader: api.LoaderTS,
},
Bundle: true,
Outfile: filepath.Join(b.outputDir, "components.js"),
Format: api.FormatESModule,
Target: api.ES2020,
Platform: api.PlatformBrowser,
Sourcemap: api.SourceMapLinked,
Write: true,
LogLevel: api.LogLevelWarning,
JSX: api.JSXAutomatic,
AbsWorkingDir: wd,
Define: map[string]string{
"process.env.NODE_ENV": fmt.Sprintf(`"%s"`, cmp.Or(map[bool]string{true: "development", false: "production"}[b.devMode], "production")),
},
}
// Minify in production
if !b.devMode {
opts.MinifyWhitespace = true
opts.MinifyIdentifiers = true
opts.MinifySyntax = true
}
// Run esbuild
result := api.Build(opts)
// Check for errors
if len(result.Errors) > 0 {
var errMsgs []string
for _, e := range result.Errors {
errMsgs = append(errMsgs, e.Text)
}
return fmt.Errorf("build errors: %s", strings.Join(errMsgs, "; "))
}
return nil
}
// generateVirtualEntry creates a virtual entry point that wraps user exports.
// User's entry must export: render, unmount, and named component exports.
func generateVirtualEntry(entryPath string) string {
// Convert to relative path with ./ prefix for import
importPath := "./" + entryPath
return fmt.Sprintf(`// Virtual entry - generated by readysite/frontend/esbuild
import * as __all from '%s';
// Assign render/unmount to window globals
(window as any).__render = __all.render;
(window as any).__unmount = __all.unmount;
// Assign all other exports (components) to window
for (const [name, value] of Object.entries(__all)) {
if (name !== 'render' && name !== 'unmount') {
(window as any)[name] = value;
}
}
`, importPath)
}