This commit is contained in:
nvms 2024-10-09 14:22:28 -04:00
commit 40d031772c
22 changed files with 1321 additions and 0 deletions

32
Makefile Normal file
View File

@ -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}'

16
README.md Normal file
View File

@ -0,0 +1,16 @@
```
░▒▓████████▓▒░░▒▓███████▓▒░▒▓███████▓▒░
░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░
░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░
░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓███████▓▒░
░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
░▒▓████████▓▒░▒▓███████▓▒░░▒▓█▓▒░░▒▓█▓▒░
```
# esr
This is a simple esbuild-powered dev server. It has livereloading. It's fast.
The end.

71
cmd/esr/main.go Normal file
View File

@ -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)
}
}

12
go.mod Normal file
View File

@ -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

13
go.sum Normal file
View File

@ -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=

124
internal/config/config.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

75
internal/esr/builder.go Normal file
View File

@ -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
}

85
internal/esr/esr.go Normal file
View File

@ -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()
}
}

125
internal/esr/init.go Normal file
View File

@ -0,0 +1,125 @@
package esr
import (
"os"
"path/filepath"
)
var indexHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>esr</title>
</head>
<body>
<div id="app"></div>
<script src="index.js" type="module"></script>
{{ livereload }}
</body>
</html>`
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)
}
}

View File

@ -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 = `<script>const source = new EventSource('/livereload'); source.onmessage = (e) => { if (e.data === 'reload') location.reload(); };</script>`

141
internal/esr/mode.go Normal file
View File

@ -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
}

View File

@ -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
})
},
}

66
internal/esr/runner.go Normal file
View File

@ -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
}

110
internal/esr/server.go Normal file
View File

@ -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, "<script src=\"/%s\"></script>", 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)
}

17
internal/esr/utils.go Normal file
View File

@ -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 <path>] [--serve] [--build] [--watch] <entrypoint>`)
}

76
internal/esr/watcher.go Normal file
View File

@ -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()
}

32
internal/glob/matcher.go Normal file
View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

View File

@ -0,0 +1,18 @@
package livereload
// const JsSnippet = `<script>
// const source = new EventSource('/livereload');
// source.onmessage = (e) => {
// if (e.data === 'reload') {
// location.reload(true);
// } else {
// console.error(e.data);
// }
// };
// source.onerror = () => {
// source.onopen = () => location.reload(true);
// };
// console.info('esr: livereload enabled');
// </script>
// `
const JsSnippet = `<script>const source = new EventSource('/livereload'); source.onmessage = (e) => { if (e.data === 'reload') location.reload(); }; console.info('esr :: livereload listening');</script>`