commit 40d031772c3032bb9c602e124a3430faec52da6d Author: nvms Date: Wed Oct 9 14:22:28 2024 -0400 first diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1e22097 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +all: help + +test: + go test . + +build: ## Builds the binary. + go build -o esr ./cmd/esr + +install: ## Installs the binary. + go install ./cmd/esr + +test-serve: ## Runs the server. + go run cmd/esr/main.go --config example/.esr.yml --serve example/src/index.ts + +test-build-watch: ## Runs esr --build + go run cmd/esr/main.go --config example/.esr.yml --watch --build example/src/index.ts + +test-build: ## Runs esr --build + go run cmd/esr/main.go --config example/.esr.yml --build example/src/index.ts + +test-init: ## Runs esr init + go run cmd/esr/main.go init + +test-run: ## Runs esr + go run cmd/esr/main.go --config example/.esr.yml --run example/src/index.ts + +test-run-watch: ## Runs esr --watch + go run cmd/esr/main.go --config example/.esr.yml --run --watch example/src/index.ts + +.PHONY: help +help: ## Show help messages for make targets + @echo esr && grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ea7a15 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +``` + + ░▒▓████████▓▒░░▒▓███████▓▒░▒▓███████▓▒░ + ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ + ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ + ░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓███████▓▒░ + ░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ + ░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ + ░▒▓████████▓▒░▒▓███████▓▒░░▒▓█▓▒░░▒▓█▓▒░ + +``` +# esr + +This is a simple esbuild-powered dev server. It has livereloading. It's fast. + +The end. diff --git a/cmd/esr/main.go b/cmd/esr/main.go new file mode 100644 index 0000000..7f39cf1 --- /dev/null +++ b/cmd/esr/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "esr/internal/config" + lib "esr/internal/esr" + "flag" + "fmt" + "os" +) + +func initCommand() { + lib.InitProject() +} + +func main() { + var ( + configPath string + serve bool + build bool + watch bool + run bool + ) + + flag.Usage = func() { + lib.ShowHelpMessage() + } + + flag.StringVar(&configPath, "config", ".esr.yml", "path to the config file") + flag.BoolVar(&serve, "serve", false, "serve") + flag.BoolVar(&build, "build", false, "build") + flag.BoolVar(&watch, "watch", false, "watch") + flag.BoolVar(&run, "run", false, "run") + + flag.Parse() + + args := flag.Args() + + if len(os.Args) > 1 && os.Args[1] == "init" { + initCommand() + return + } + + if len(args) < 1 { + fmt.Println("Error: no entrypoint specified") + os.Exit(1) + } + + entryPoint := args[0] + + if _, err := os.Stat(entryPoint); os.IsNotExist(err) { + fmt.Printf("Error: entrypoint '%s' does not exist\n", entryPoint) + os.Exit(1) + } + + cfg, err := config.ReadConfig(configPath) + + if err != nil { + fmt.Println("esr :: Failed to read config:", err) + + cfg = config.GetDefaultConfig() + } + + esr := lib.NewEsr(cfg, entryPoint) + + if build || serve || run { + lib.ExecuteTask(esr, build, serve, run, watch) + } else if watch { + fmt.Println("--watch given, but no action specified (e.g. --build, --serve, --run)") + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e1ef2a5 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module esr + +go 1.21.5 + +require ( + github.com/bmatcuk/doublestar/v3 v3.0.0 + github.com/evanw/esbuild v0.24.0 + github.com/fsnotify/fsnotify v1.7.0 + gopkg.in/yaml.v2 v2.4.0 +) + +require golang.org/x/sys v0.4.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8d82dd6 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/bmatcuk/doublestar/v3 v3.0.0 h1:TQtVPlDnAYwcrVNB2JiGuMc++H5qzWZd9PhkNo5WyHI= +github.com/bmatcuk/doublestar/v3 v3.0.0/go.mod h1:6PcTVMw80pCY1RVuoqu3V++99uQB3vsSYKPTd8AWA0k= +github.com/evanw/esbuild v0.24.0 h1:GZ78naTLp7FKr+K7eNuM/SLs5maeiHYRPsTg6kmdsSE= +github.com/evanw/esbuild v0.24.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..059c955 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,124 @@ +package config + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v2" +) + +type Common struct { + Minify bool `yaml:"minify,omitempty"` + MinifyWhitespace bool `yaml:"minifyWhitespace"` + MinifyIdentifiers bool `yaml:"minifyIdentifiers"` + Bundle bool `yaml:"bundle"` + Sourcemap bool `yaml:"sourcemap"` + Platform string `yaml:"platform"` + Format string `yaml:"format"` + Outdir string `yaml:"outdir"` + JSX string `yaml:"jsx"` + JSXFactory string `yaml:"jsxFactory"` + JSXImportSource string `yaml:"jsxImportSource"` + Loader map[string]string `yaml:"loader,omitempty"` + External []string `yaml:"external,omitempty"` +} + +type Config struct { + Serve *ServeConfig `yaml:"serve,omitempty"` + Build *BuildConfig `yaml:"build,omitempty"` + Run *RunConfig `yaml:"run,omitempty"` + Watch *WatchConfig `yaml:"watch,omitempty"` + Common `yaml:",inline"` +} + +type ServeConfig struct { + Html string `yaml:"html"` + Port int `yaml:"port"` + Common `yaml:",inline"` +} + +type BuildConfig struct { + Common `yaml:",inline"` +} + +type RunConfig struct { + Runtime string `yaml:"runtime"` + Common `yaml:",inline"` +} + +type WatchConfig struct { + Paths []string `yaml:"paths"` +} + +func GetDefaultConfig() *Config { + defaultConfig := Common{ + Minify: false, + MinifyWhitespace: false, + MinifyIdentifiers: false, + Bundle: true, + Sourcemap: true, + Platform: "browser", + Format: "esm", + Outdir: "public", + JSX: "automatic", + JSXFactory: "React.createElement", + JSXImportSource: "", + Loader: map[string]string{}, + External: []string{}, + } + + return &Config{ + Serve: &ServeConfig{ + Html: "public/index.html", + Port: 1234, + Common: defaultConfig, + }, + Build: &BuildConfig{ + Common: defaultConfig, + }, + Run: &RunConfig{ + Runtime: "node", + Common: defaultConfig, + }, + Watch: &WatchConfig{ + Paths: []string{"src/**/*.{ts,tsx,js,jsx}"}, + }, + Common: defaultConfig, + } +} + +func ReadConfig(filename string) (*Config, error) { + absPath, err := filepath.Abs(filename) + if err != nil { + return nil, err + } + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + config := GetDefaultConfig() + + if err := yaml.Unmarshal(data, &config.Build); err != nil { + return nil, err + } + + if err := yaml.Unmarshal(data, &config.Serve); err != nil { + return nil, err + } + + if err := yaml.Unmarshal(data, &config.Run); err != nil { + return nil, err + } + + if err := yaml.Unmarshal(data, &config.Watch); err != nil { + return nil, err + } + + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, err + } + + return config, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5361daf --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,132 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func setupTestConfigFile(content string) (string, error) { + // Create a temporary file + dir, err := os.MkdirTemp("", "config_test") + if err != nil { + return "", err + } + + filePath := filepath.Join(dir, "test_config.yml") + // Write content to the temp file + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return "", err + } + + return filePath, nil +} + +func TestReadConfigWithDefaultValues(t *testing.T) { + ymlContent := `bundle: true +platform: browser +format: esm +sourcemap: true +outdir: public +html: index.html +port: 8080 +` + + filePath, err := setupTestConfigFile(ymlContent) + if err != nil { + t.Fatalf("Failed to setup test config file: %v", err) + } + defer os.RemoveAll(filepath.Dir(filePath)) + + config, err := ReadConfig(filePath) + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + + expected := &Config{ + Common: Common{ + Bundle: true, + Sourcemap: true, + Platform: "browser", + Format: "esm", + Outdir: "public", + }, + } + + if config.Platform != expected.Platform { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Platform, expected.Platform) + } + + if config.Format != expected.Format { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Format, expected.Format) + } + + if config.Outdir != expected.Outdir { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Outdir, expected.Outdir) + } + + if config.Bundle != expected.Bundle { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Bundle, expected.Bundle) + } + + if config.Sourcemap != expected.Sourcemap { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Sourcemap, expected.Sourcemap) + } + + if config.Serve.Html != "index.html" { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Serve.Html, "index.html") + } + + if config.Serve.Port != 8080 { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Serve.Port, 8080) + } +} + +func TestReadConfigWithCustomBuildConfig(t *testing.T) { + ymlContent := `build: + minify: true + minifyWhitespace: true + sourcemap: false +` + + filePath, err := setupTestConfigFile(ymlContent) + if err != nil { + t.Fatalf("Failed to setup test config file: %v", err) + } + defer os.RemoveAll(filepath.Dir(filePath)) + + config, err := ReadConfig(filePath) + if err != nil { + t.Fatalf("Failed to read config: %v", err) + } + + expected := &BuildConfig{ + Common: Common{ + Minify: true, + MinifyWhitespace: true, + MinifyIdentifiers: false, + Bundle: true, + Sourcemap: false, + }, + } + + if config.Build.Minify != expected.Minify { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Build.Minify, expected.Minify) + } + + if config.Build.MinifyWhitespace != expected.MinifyWhitespace { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Build.MinifyWhitespace, expected.MinifyWhitespace) + } + + if config.Build.MinifyIdentifiers != expected.MinifyIdentifiers { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Build.MinifyIdentifiers, expected.MinifyIdentifiers) + } + + if config.Build.Bundle != expected.Bundle { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Build.Bundle, expected.Bundle) + } + + if config.Build.Sourcemap != expected.Sourcemap { + t.Errorf("ReadConfig().SomeField = %+v, expected %+v", config.Build.Sourcemap, expected.Sourcemap) + } +} diff --git a/internal/esr/builder.go b/internal/esr/builder.go new file mode 100644 index 0000000..822a4af --- /dev/null +++ b/internal/esr/builder.go @@ -0,0 +1,75 @@ +package esr + +import ( + "esr/internal/config" + "fmt" + "os" + "path/filepath" + + "github.com/evanw/esbuild/pkg/api" +) + +type Builder struct { + Config *config.Config + EntryPoint string + BuiltFiles []string +} + +func NewBuilder(c *config.Config, e string) *Builder { + return &Builder{ + Config: c, + EntryPoint: e, + } +} + +func (b *Builder) Build(buildOptions *api.BuildOptions) error { + result := api.Build(*buildOptions) + if len(result.Errors) > 0 { + return fmt.Errorf("esr :: build failed: %v", result.Errors) + } + + b.BuiltFiles = []string{} + + for _, file := range result.OutputFiles { + if filepath.Ext(file.Path) != ".map" { + dir := filepath.Dir(file.Path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return fmt.Errorf("esr :: failed to create directory: %v", err) + } + } + + if err := os.WriteFile(file.Path, file.Contents, os.ModePerm); err != nil { + return fmt.Errorf("esr :: failed to write file: %v", err) + } + fmt.Printf("esr :: wrote: %s\n", filepath.Join(b.Config.Outdir, filepath.Base(file.Path))) + + if filepath.Ext(file.Path) == ".js" { + b.BuiltFiles = append(b.BuiltFiles, file.Path) + } + } else { + if err := os.WriteFile(file.Path, file.Contents, os.ModePerm); err != nil { + return fmt.Errorf("esr :: failed to write file: %v", err) + } + } + } + + return nil +} + +func (b *Builder) StartWatch(opts *api.BuildOptions, callback func()) error { + paths := append([]string{b.EntryPoint}, b.Config.Watch.Paths...) + watcher, err := NewWatcher(paths) + if err != nil { + return fmt.Errorf("failed to start watcher: %v", err) + } + + watcher.Start(func() { + if err := b.Build(opts); err != nil { + fmt.Println("Watch build error:", err) + } + callback() + }) + + return nil +} diff --git a/internal/esr/esr.go b/internal/esr/esr.go new file mode 100644 index 0000000..9e5f3c4 --- /dev/null +++ b/internal/esr/esr.go @@ -0,0 +1,85 @@ +package esr + +import ( + "esr/internal/config" + "fmt" + + "github.com/evanw/esbuild/pkg/api" +) + +type Esr struct { + Config *config.Config + Builder *Builder + Server *Server + Runner *Runner +} + +func NewEsr(c *config.Config, entryPoint string) *Esr { + builder := NewBuilder(c, entryPoint) + server := NewDevServer(c, entryPoint, builder) + + return &Esr{ + Config: c, + Builder: builder, + Server: server, + Runner: NewRunner(c, builder, entryPoint), + } +} + +func ExecuteTask(esr *Esr, build, serve, run, watch bool) { + entryPoint := esr.Builder.EntryPoint + + if build { + opts := ModeOptions(esr.Config, ModeBuilder, entryPoint, "") + if err := esr.Builder.Build(opts); err != nil { + fmt.Println("Build error:", err) + return + } + } + + if serve { + opts := ModeOptions(esr.Config, ModeServer, entryPoint, "") + if err := esr.Builder.Build(opts); err != nil { + fmt.Println("Build before serve error:", err) + return + } + startServerWithWatching(esr, opts) + } + + if run { + esr.Runner.Run(entryPoint) + } + + if watch && !(serve || run) { + opts := ModeOptions(esr.Config, ModeServer, entryPoint, "") + err := esr.Builder.StartWatch(opts, esr.generalCallback(run, entryPoint)) + if err != nil { + fmt.Println("Watch error:", err) + } + } +} + +func startServerWithWatching(esr *Esr, opts *api.BuildOptions) { + err := esr.Builder.StartWatch(opts, esr.serveCallback()) + if err != nil { + fmt.Println("Server watch error:", err) + } + + if err := esr.Server.Start(); err != nil { + fmt.Println("Server error:", err) + } +} + +func (esr *Esr) generalCallback(run bool, entryPoint string) func() { + return func() { + if run { + esr.Runner.Run(entryPoint) + } + } +} + +func (esr *Esr) serveCallback() func() { + return func() { + esr.Server.LiveReload.Reload() + } +} diff --git a/internal/esr/init.go b/internal/esr/init.go new file mode 100644 index 0000000..452c8ee --- /dev/null +++ b/internal/esr/init.go @@ -0,0 +1,125 @@ +package esr + +import ( + "os" + "path/filepath" +) + +var indexHtml = ` + + + + + esr + + +
+ + {{ livereload }} + +` + +var defaultConfig = `bundle: true +platform: browser +format: esm +sourcemap: true +outdir: public + +watch: + paths: ['src/**/*.{ts,tsx,js,jsx,css,scss,html}'] + +serve: + html: public/index.html + port: 1234 + +build: + minify: true + minifyWhitespace: true + minifyIdentifiers: true + sourcemap: false + +run: + runtime: bun + sourcemap: false + +jsx: transform +jsxFactory: h +` + +func CreateDefaultPackageJson() string { + cwd, err := os.Getwd() + if err != nil { + Die("failed to get current working directory: %v", err) + } + + return `{ + "name": "` + filepath.Base(cwd) + `", + "version": "0.0.1", + "scripts": { + "serve": "esr --serve src/index.ts", + "build": "esr --build src/index.ts", + "build:watch": "esr --build --watch src/index.ts", + "run": "esr --run src/index.ts", + "run:watch": "esr --run --watch src/index.ts" + }, + "keywords": [], + "author": "", + "license": "ISC" +}` +} + +func InitProject() { + cwd, err := os.Getwd() + if err != nil { + Die("failed to get current working directory: %v", err) + } + + cwdFiles, err := os.ReadDir(cwd) + if err != nil { + Die("failed to read current working directory: %v", err) + } + + if len(cwdFiles) > 0 { + Die("current working directory is not empty") + } + + publicDir := filepath.Join(cwd, "public") + if err := os.MkdirAll(publicDir, 0755); err != nil { + Die("failed to create public directory: %v", err) + } + + indexHtmlPath := filepath.Join(publicDir, "index.html") + if err := os.WriteFile(indexHtmlPath, []byte(indexHtml), 0644); err != nil { + Die("failed to write index.html: %v", err) + } + + srcDir := filepath.Join(cwd, "src") + if err := os.MkdirAll(srcDir, 0755); err != nil { + Die("failed to create src directory: %v", err) + } + + indexTsPath := filepath.Join(srcDir, "index.ts") + if err := os.WriteFile(indexTsPath, []byte("console.log('Hello, world!')\n"), 0644); err != nil { + Die("failed to write index.ts: %v", err) + } + + esrYmlPath := filepath.Join(cwd, ".esr.yml") + if err := os.WriteFile(esrYmlPath, []byte(defaultConfig), 0644); err != nil { + Die("failed to write .esr.yml: %v", err) + } + + packageJsonPath := filepath.Join(cwd, "package.json") + if err := os.WriteFile(packageJsonPath, []byte(CreateDefaultPackageJson()), 0644); err != nil { + Die("failed to write package.json: %v", err) + } + + tsconfigJsonPath := filepath.Join(cwd, "tsconfig.json") + if err := os.WriteFile(tsconfigJsonPath, []byte(`{ + "compilerOptions": { + "jsx": "preserve", + "jsxFactory": "h" + } +}`), 0644); err != nil { + Die("failed to write tsconfig.json: %v", err) + } +} diff --git a/internal/esr/livereload.go b/internal/esr/livereload.go new file mode 100644 index 0000000..f6c3c6f --- /dev/null +++ b/internal/esr/livereload.go @@ -0,0 +1,84 @@ +package esr + +import ( + "fmt" + "net/http" + "time" +) + +type LiveReload struct { + clients map[chan string]bool + incoming chan chan string + outgoing chan chan string + messages chan string +} + +func NewLiveReload() *LiveReload { + return &LiveReload{ + clients: make(map[chan string]bool), + incoming: make(chan chan string), + outgoing: make(chan chan string), + messages: make(chan string), + } +} + +func (l *LiveReload) ServeHTTP(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + + clientChan := make(chan string) + l.incoming <- clientChan + defer func() { l.outgoing <- clientChan }() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + + for { + select { + case message, ok := <-clientChan: + if !ok { + return + } + _, _ = fmt.Fprintf(w, "data: %s\n\n", message) + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + +func (l *LiveReload) Start() { + go func() { + for { + select { + case client := <-l.incoming: + l.clients[client] = true + case client := <-l.outgoing: + delete(l.clients, client) + close(client) + case msg := <-l.messages: + for client := range l.clients { + client <- msg + } + } + } + }() + + go func() { + for range time.Tick(time.Minute) { + l.messages <- "waiting" + } + }() +} + +func (l *LiveReload) Reload() { + l.messages <- "reload" +} + +// JsSnippet generates a JS snippet for including in the template. +// const JsSnippet = `` diff --git a/internal/esr/mode.go b/internal/esr/mode.go new file mode 100644 index 0000000..cb9963b --- /dev/null +++ b/internal/esr/mode.go @@ -0,0 +1,141 @@ +package esr + +import ( + "esr/internal/config" + "esr/internal/esr/plugins" + "fmt" + + "github.com/evanw/esbuild/pkg/api" +) + +type ModeType int + +const ( + ModeBuilder ModeType = iota + ModeServer + ModeRunner +) + +func ModeOptions(config *config.Config, mode ModeType, entryPoint, tempFilePath string) *api.BuildOptions { + buildOptions := api.BuildOptions{ + EntryPoints: []string{entryPoint}, + Plugins: []api.Plugin{plugins.EnvPlugin}, + } + + // Apply common settings based on the mode + getSourceMap := func(sourcemap bool) api.SourceMap { + if sourcemap { + return api.SourceMapLinked + } + return api.SourceMapNone + } + + switch mode { + case ModeBuilder: + buildOptions.Bundle = config.Build.Bundle + buildOptions.Outdir = config.Build.Outdir + buildOptions.MinifySyntax = config.Build.Minify + buildOptions.MinifyWhitespace = config.Build.MinifyWhitespace + buildOptions.MinifyIdentifiers = config.Build.MinifyIdentifiers + buildOptions.JSXFactory = config.Build.JSXFactory + buildOptions.JSXImportSource = config.Build.JSXImportSource + buildOptions.Sourcemap = getSourceMap(config.Build.Sourcemap) + case ModeServer: + buildOptions.Bundle = config.Serve.Bundle + buildOptions.Outdir = config.Serve.Outdir + buildOptions.MinifySyntax = config.Serve.Minify + buildOptions.MinifyWhitespace = config.Serve.MinifyWhitespace + buildOptions.MinifyIdentifiers = config.Serve.MinifyIdentifiers + buildOptions.JSXFactory = config.Serve.JSXFactory + buildOptions.JSXImportSource = config.Serve.JSXImportSource + buildOptions.Sourcemap = getSourceMap(config.Serve.Sourcemap) + case ModeRunner: + buildOptions.Bundle = true + buildOptions.Format = api.FormatESModule + buildOptions.Platform = api.PlatformNode + buildOptions.Outfile = tempFilePath + buildOptions.Outdir = "" + buildOptions.Sourcemap = getSourceMap(config.Run.Sourcemap) + } + + // Apply format + switch config.Format { + case "esm": + buildOptions.Format = api.FormatESModule + case "cjs": + buildOptions.Format = api.FormatCommonJS + case "iife": + buildOptions.Format = api.FormatIIFE + default: + buildOptions.Format = api.FormatDefault + fmt.Printf("Unknown format: %s, using default\n", config.Format) + } + + // Apply platform + switch config.Platform { + case "browser": + buildOptions.Platform = api.PlatformBrowser + case "node": + buildOptions.Platform = api.PlatformNode + case "neutral": + buildOptions.Platform = api.PlatformNeutral + default: + buildOptions.Platform = api.PlatformDefault + fmt.Printf("Unknown platform: %s, using default\n", config.Platform) + } + + switch config.JSX { + case "preserve": + buildOptions.JSX = api.JSXPreserve + case "transform": + buildOptions.JSX = api.JSXTransform + case "automatic": + buildOptions.JSX = api.JSXAutomatic + default: + buildOptions.JSX = api.JSXPreserve + fmt.Printf("Unknown JSX: %s, using default\n", config.JSX) + } + + // Apply loader + buildOptions.Loader = make(map[string]api.Loader) + for k, v := range config.Loader { + switch v { + case "file": + buildOptions.Loader[k] = api.LoaderFile + case "dataurl": + buildOptions.Loader[k] = api.LoaderDataURL + case "binary": + buildOptions.Loader[k] = api.LoaderBinary + case "base64": + buildOptions.Loader[k] = api.LoaderBase64 + case "copy": + buildOptions.Loader[k] = api.LoaderCopy + case "text": + buildOptions.Loader[k] = api.LoaderText + case "js": + buildOptions.Loader[k] = api.LoaderJS + case "jsx": + buildOptions.Loader[k] = api.LoaderJSX + case "tsx": + buildOptions.Loader[k] = api.LoaderTSX + case "ts": + buildOptions.Loader[k] = api.LoaderTS + case "json": + buildOptions.Loader[k] = api.LoaderJSON + case "css": + buildOptions.Loader[k] = api.LoaderCSS + case "globalcss": + buildOptions.Loader[k] = api.LoaderGlobalCSS + case "localcss": + buildOptions.Loader[k] = api.LoaderLocalCSS + case "empty": + buildOptions.Loader[k] = api.LoaderEmpty + case "default": + buildOptions.Loader[k] = api.LoaderDefault + default: + buildOptions.Loader[k] = api.LoaderNone + } + } + + return &buildOptions +} diff --git a/internal/esr/plugins/env.go b/internal/esr/plugins/env.go new file mode 100644 index 0000000..ffe555f --- /dev/null +++ b/internal/esr/plugins/env.go @@ -0,0 +1,40 @@ +package plugins + +import ( + "encoding/json" + "os" + "strings" + + "github.com/evanw/esbuild/pkg/api" +) + +var EnvPlugin = api.Plugin{ + Name: "env", + Setup: func(build api.PluginBuild) { + build.OnResolve(api.OnResolveOptions{Filter: `^env$`}, + func(args api.OnResolveArgs) (api.OnResolveResult, error) { + return api.OnResolveResult{ + Path: args.Path, + Namespace: "env-ns", + }, nil + }) + build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: "env-ns"}, + func(args api.OnLoadArgs) (api.OnLoadResult, error) { + mappings := make(map[string]string) + for _, item := range os.Environ() { + if equals := strings.IndexByte(item, '='); equals != -1 { + mappings[item[:equals]] = item[equals+1:] + } + } + bytes, err := json.Marshal(mappings) + if err != nil { + return api.OnLoadResult{}, err + } + contents := string(bytes) + return api.OnLoadResult{ + Contents: &contents, + Loader: api.LoaderJSON, + }, nil + }) + }, +} diff --git a/internal/esr/runner.go b/internal/esr/runner.go new file mode 100644 index 0000000..4e1b5e5 --- /dev/null +++ b/internal/esr/runner.go @@ -0,0 +1,66 @@ +package esr + +import ( + "esr/internal/config" + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" + "time" +) + +type Runner struct { + Config *config.Config + EntryPoint string + Builder *Builder + TempFilePath string +} + +func NewRunner(c *config.Config, b *Builder, e string) *Runner { + return &Runner{ + Config: c, + EntryPoint: e, + Builder: b, + TempFilePath: newTempFilePath(), + } +} + +func newTempFilePath() string { + timestamp := time.Now().UnixNano() + tempDir := os.TempDir() + return filepath.Join(tempDir, fmt.Sprintf("esr-%d.mjs", timestamp)) +} + +func (r *Runner) Run(entryPoint string) { + opts := ModeOptions(r.Config, ModeRunner, entryPoint, r.TempFilePath) + opts.Outfile = r.TempFilePath + + if err := r.Builder.Build(opts); err != nil { + fmt.Println("Build error:", err) + return + } + + cmd := exec.Command(r.Config.Run.Runtime, r.TempFilePath) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + if err := cmd.Start(); err != nil { + fmt.Println("Error starting process:", err) + os.Exit(1) + } + + go func() { + if err := cmd.Wait(); err != nil { + fmt.Printf("Process finished with error: %v\n", err) + } else { + fmt.Println("Process finished successfully") + } + }() +} + +func (r *Runner) Stop(cmd *exec.Cmd) error { + if cmd.Process != nil { + return cmd.Process.Signal(syscall.SIGINT) + } + return nil +} diff --git a/internal/esr/server.go b/internal/esr/server.go new file mode 100644 index 0000000..e9deb23 --- /dev/null +++ b/internal/esr/server.go @@ -0,0 +1,110 @@ +package esr + +import ( + "bytes" + "esr/internal/config" + "esr/internal/livereload" + "fmt" + "html/template" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" +) + +type Server struct { + Config *config.Config + EntryPoint string + LiveReload *LiveReload + Builder *Builder +} + +func NewDevServer(cfg *config.Config, entryPoint string, builder *Builder) *Server { + return &Server{ + Config: cfg, + EntryPoint: entryPoint, + LiveReload: NewLiveReload(), + Builder: builder, + } +} + +func (s *Server) Start() error { + s.LiveReload.Start() + + http.Handle("/livereload", s.LiveReload) + http.Handle("/", s) + + go func() { + log.Printf("esr :: Serving on http://localhost:%d\n", s.Config.Serve.Port) + if err := http.ListenAndServe(fmt.Sprintf(":%d", s.Config.Serve.Port), nil); err != nil { + log.Printf("esr :: Server error: %v\n", err) + } + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + <-sigCh + log.Println("esr :: Signal received, gracefully shutting down.") + return nil +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "no-store") + + dir := filepath.Dir(s.Config.Serve.Html) + root := filepath.Join(dir, filepath.Clean(r.URL.Path)) + + if stat, err := os.Stat(root); err != nil || stat.IsDir() { + if content, err := s.BuildIndex(); err == nil { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(content)) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + http.ServeFile(w, r, root) +} + +func (s *Server) BuildIndex() (string, error) { + target, err := filepath.Abs(s.Config.Serve.Html) + if err != nil { + return "", err + } + + tmpl, err := template.New("index.html").Funcs(template.FuncMap{ + "livereload": func() template.HTML { return template.HTML(livereload.JsSnippet) }, + "js": func() template.HTML { + var scripts strings.Builder + if s.Builder != nil { + for _, file := range s.Builder.BuiltFiles { + fmt.Fprintf(&scripts, "", filepath.Base(file)) + } + } + return template.HTML(scripts.String()) + }, + }).ParseFiles(target) + + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, nil); err != nil { + return "", err + } + + return buf.String(), nil +} + +func (s *Server) TriggerRebuild() error { + opts := ModeOptions(s.Config, ModeServer, s.Builder.EntryPoint, "") + return s.Builder.Build(opts) +} diff --git a/internal/esr/utils.go b/internal/esr/utils.go new file mode 100644 index 0000000..ade5a65 --- /dev/null +++ b/internal/esr/utils.go @@ -0,0 +1,17 @@ +package esr + +import ( + "fmt" + "os" +) + +func Die(format string, v ...interface{}) { + fmt.Fprintf(os.Stderr, format, v...) + os.Stderr.Write([]byte("\n")) + os.Exit(1) +} + +func ShowHelpMessage() { + fmt.Println(`Usage: + esr [--config ] [--serve] [--build] [--watch] `) +} diff --git a/internal/esr/watcher.go b/internal/esr/watcher.go new file mode 100644 index 0000000..90e0a8d --- /dev/null +++ b/internal/esr/watcher.go @@ -0,0 +1,76 @@ +package esr + +import ( + "esr/internal/glob" + "fmt" + + "github.com/fsnotify/fsnotify" +) + +type Watcher struct { + watcher *fsnotify.Watcher + paths []string +} + +func NewWatcher(paths []string) (*Watcher, error) { + fsWatcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("failed to create fsnotify watcher: %v", err) + } + + var newPaths []string + for _, path := range paths { + matchedPaths, err := glob.GlobFiles(path) + if err != nil { + return nil, fmt.Errorf("failed to glob files: %v", err) + } + newPaths = append(newPaths, matchedPaths...) + } + + uniquePaths := make(map[string]struct{}) + for _, path := range newPaths { + uniquePaths[path] = struct{}{} + } + + finalPaths := make([]string, 0, len(uniquePaths)) + for path := range uniquePaths { + finalPaths = append(finalPaths, path) + } + + return &Watcher{ + watcher: fsWatcher, + paths: finalPaths, + }, nil +} + +func (w *Watcher) Start(callback func()) { + for _, path := range w.paths { + if err := w.watcher.Add(path); err != nil { + panic(err) + } + } + + go func() { + defer w.watcher.Close() + for { + select { + case event, ok := <-w.watcher.Events: + if !ok { + return + } + if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { + go callback() + } + case err, ok := <-w.watcher.Errors: + if !ok { + return + } + panic(err) + } + } + }() +} + +func (w *Watcher) Close() error { + return w.watcher.Close() +} diff --git a/internal/glob/matcher.go b/internal/glob/matcher.go new file mode 100644 index 0000000..d58539d --- /dev/null +++ b/internal/glob/matcher.go @@ -0,0 +1,32 @@ +package glob + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bmatcuk/doublestar/v3" +) + +func GlobFiles(pattern string) ([]string, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get current working directory: %w", err) + } + + matches, err := doublestar.Glob(filepath.Join(cwd, pattern)) + if err != nil { + return nil, fmt.Errorf("failed to match pattern: %w", err) + } + + for i, match := range matches { + matches[i] = strings.TrimPrefix(match, cwd) + } + + for i, match := range matches { + matches[i] = strings.TrimPrefix(match, "/") + } + + return matches, nil +} diff --git a/internal/glob/matcher_test.go b/internal/glob/matcher_test.go new file mode 100644 index 0000000..e11a799 --- /dev/null +++ b/internal/glob/matcher_test.go @@ -0,0 +1,52 @@ +package glob + +import ( + "reflect" + "testing" +) + +func TestGlobFiles(t *testing.T) { + tests := []struct { + name string + pattern string + want []string + wantErr bool + }{ + { + name: "Test with valid pattern", + pattern: "testfiles/**/*.{txt,md}", + want: []string{ + "testfiles/subdir1/test1.txt", + "testfiles/subdir2/test2.md", + }, + wantErr: false, + }, + { + name: "Test with partial pattern", + pattern: "testfiles/**/test1.txt", + want: []string{ + "testfiles/subdir1/test1.txt", + }, + wantErr: false, + }, + { + name: "Test with invalid pattern", + pattern: "testfiles/**/test3.txt", + want: nil, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GlobFiles(tt.pattern) + if (err != nil) != tt.wantErr { + t.Errorf("GlobFiles() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GlobFiles() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/glob/testfiles/subdir1/test1.txt b/internal/glob/testfiles/subdir1/test1.txt new file mode 100644 index 0000000..e69de29 diff --git a/internal/glob/testfiles/subdir2/test2.md b/internal/glob/testfiles/subdir2/test2.md new file mode 100644 index 0000000..e69de29 diff --git a/internal/livereload/livereload.go b/internal/livereload/livereload.go new file mode 100644 index 0000000..2031d3d --- /dev/null +++ b/internal/livereload/livereload.go @@ -0,0 +1,18 @@ +package livereload + +// const JsSnippet = ` +// ` +const JsSnippet = ``