├── bun.lockb ├── assets ├── css │ ├── main.css │ └── reset.css └── main.tsx ├── go.mod ├── tsconfig.json ├── utils ├── err.go ├── slice.go └── file.go ├── go.sum ├── README.md ├── server ├── home.go ├── sse.go └── vite.go ├── .gitignore ├── package.json ├── templates └── layout.templ ├── Makefile ├── tsconfig.node.json ├── vite.config.ts ├── tsconfig.app.json └── main.go /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Grafikart/go-web-boilerplate/HEAD/bun.lockb -------------------------------------------------------------------------------- /assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import "reset.css"; 2 | 3 | body { 4 | background-color: grey; 5 | } 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module grafikart/boilerplate 2 | 3 | go 1.22.4 4 | 5 | require github.com/a-h/templ v0.2.747 // indirect 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /utils/err.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func Force[K interface{}](v K, err error) K { 4 | if err != nil { 5 | panic(err) 6 | } 7 | 8 | return v 9 | } 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg= 2 | github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4= 3 | -------------------------------------------------------------------------------- /assets/main.tsx: -------------------------------------------------------------------------------- 1 | import "./css/main.css"; 2 | 3 | const evtSource = new EventSource("/sse"); 4 | 5 | evtSource.onmessage = (e) => { 6 | console.log("sse message", e); 7 | }; 8 | 9 | console.log("hello"); 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go web boilerplate 2 | 3 | This is a boilerplate when starting a new web server using golang. 4 | 5 | ## Features 6 | 7 | - Render HTML pages 8 | - Handle vite assets for both prod and dev 9 | - Simple implementation of Server Sent Event 10 | - Make commands 11 | -------------------------------------------------------------------------------- /server/home.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "grafikart/boilerplate/templates" 5 | "net/http" 6 | ) 7 | 8 | func HomeHandler(w http.ResponseWriter, r *http.Request) { 9 | w.Header().Set("Content-Type", "text/html") 10 | component := templates.Layout("John") 11 | component.Render(r.Context(), w) 12 | } 13 | -------------------------------------------------------------------------------- /utils/slice.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func RemoveItem[K comparable](s []K, item K) []K { 4 | pos := -1 5 | for k, v := range s { 6 | if v == item { 7 | pos = k 8 | continue 9 | } 10 | } 11 | if pos == -1 { 12 | return s 13 | } 14 | return RemoveAt(s, pos) 15 | } 16 | 17 | func RemoveAt[K comparable](s []K, i int) []K { 18 | s[i] = s[len(s)-1] 19 | return s[:len(s)-1] 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | templates/*.go 2 | templates/*.txt 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | pnpm-debug.log* 11 | lerna-debug.log* 12 | 13 | public/assets 14 | node_modules 15 | dist 16 | dist-ssr 17 | *.local 18 | 19 | # Editor directories and files 20 | .vscode/* 21 | !.vscode/extensions.json 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prepmaverick", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "preact": "^10.23.1" 13 | }, 14 | "devDependencies": { 15 | "@preact/preset-vite": "^2.9.0", 16 | "typescript": "^5.5.3", 17 | "vite": "^5.4.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /templates/layout.templ: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | templ Layout(name string) { 4 | 5 | 6 | 7 | 8 | 9 | Go boilerplate 10 | @templ.Raw(ctx.Value("assets").(string)) 11 | 12 | 13 |

Hello {name}

14 |
15 | { children... } 16 |
17 | 18 | 19 | } 20 | -------------------------------------------------------------------------------- /utils/file.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | ) 9 | 10 | // Read and parse a JSON file 11 | func ParseJsonFile(f fs.File, v any) error { 12 | // Read the file content 13 | bytes, err := io.ReadAll(f) 14 | if err != nil { 15 | return fmt.Errorf("Error reading vite manifest file: %v", err) 16 | } 17 | 18 | // Parse the JSON data 19 | err = json.Unmarshal(bytes, &v) 20 | if err != nil { 21 | return fmt.Errorf("Error parsing vite manifest: %v", err) 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: ## Show this help 3 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 4 | 5 | .PHONY:dev 6 | dev: public/assets/main.js ## Start the development server 7 | parallel -j 3 --line-buffer ::: "APP_ENV=dev gow -r=false run ." "bun run dev" "templ generate --watch" 8 | 9 | .PHONY: build 10 | build: ## Generates executable 11 | bun run build 12 | go build 13 | 14 | public/assets/main.js: 15 | mkdir -p public/assets 16 | touch public/assets/main.js 17 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import preact from "@preact/preset-vite"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [preact()], 7 | server: { 8 | port: 3000, 9 | cors: true, 10 | }, 11 | base: "/", 12 | build: { 13 | manifest: true, 14 | outDir: "./public/assets/", 15 | rollupOptions: { 16 | input: { 17 | main: "./assets/main.tsx", 18 | }, 19 | output: { 20 | entryFileNames: "[name]-[hash].js", 21 | assetFileNames: "[name]-[hash][extname]", 22 | chunkFileNames: "[name]-[hash][extname]", 23 | }, 24 | }, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | "paths": { 9 | "react": ["./node_modules/preact/compat/"], 10 | "react-dom": ["./node_modules/preact/compat/"] 11 | }, 12 | 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "isolatedModules": true, 17 | "moduleDetection": "force", 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | "jsxImportSource": "preact", 21 | 22 | /* Linting */ 23 | "strict": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "noFallthroughCasesInSwitch": true 27 | }, 28 | "include": ["assets"] 29 | } 30 | -------------------------------------------------------------------------------- /server/sse.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "grafikart/boilerplate/utils" 6 | "net/http" 7 | "sync" 8 | ) 9 | 10 | var channels []*chan string 11 | var mu sync.Mutex 12 | 13 | func PushMessage(msg string) { 14 | fmt.Printf("Sending to %v:", len(channels)) 15 | for _, ch := range channels { 16 | *ch <- msg 17 | } 18 | } 19 | 20 | func SSEHandler(w http.ResponseWriter, r *http.Request) { 21 | w.Header().Set("Content-Type", "text/event-stream") 22 | w.Header().Set("Cache-Control", "no-cache") 23 | w.Header().Set("Connection", "keep-alive") 24 | 25 | flusher, ok := w.(http.Flusher) 26 | 27 | if !ok { 28 | fmt.Println("Could not init http.Flusher") 29 | return 30 | } 31 | 32 | mu.Lock() 33 | ch := make(chan string) 34 | channels = append(channels, &ch) 35 | mu.Unlock() 36 | done := r.Context().Done() 37 | 38 | defer func() { 39 | mu.Lock() 40 | close(ch) 41 | channels = utils.RemoveItem(channels, &ch) 42 | ch = nil 43 | mu.Unlock() 44 | }() 45 | 46 | for { 47 | select { 48 | case <-done: 49 | return 50 | case msg := <-ch: 51 | fmt.Fprintf(w, "data: %s\n\n", msg) 52 | flusher.Flush() 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "grafikart/boilerplate/server" 8 | "io/fs" 9 | "log" 10 | "net/http" 11 | ) 12 | 13 | //go:embed all:public 14 | var assets embed.FS 15 | 16 | func main() { 17 | publicFS, err := fs.Sub(assets, "public") 18 | if err != nil { 19 | panic(fmt.Sprintf("Cannot sub public directory from %v", err)) 20 | } 21 | 22 | viteAssets := server.NewViteAssets(publicFS) 23 | frontMiddleware := createFrontEndMiddleware(*viteAssets) 24 | publicServer := http.FileServer(http.FS(publicFS)) 25 | 26 | // Static Assets 27 | http.HandleFunc("/sse", server.SSEHandler) 28 | http.HandleFunc("/assets/", viteAssets.ServeAssets) 29 | 30 | // FrontEnd URLs 31 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 32 | // Serve the root 33 | if r.URL.Path == "/" { 34 | frontMiddleware(server.HomeHandler)(w, r) 35 | return 36 | } 37 | // Otherwise serve public files 38 | publicServer.ServeHTTP(w, r) 39 | }) 40 | fmt.Println("Server is running on http://localhost:8080") 41 | log.Fatal(http.ListenAndServe(":8080", nil)) 42 | } 43 | 44 | func createFrontEndMiddleware(vite server.ViteAssets) func(func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { 45 | html := vite.GetHeadHTML() 46 | return func(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { 47 | return func(w http.ResponseWriter, r *http.Request) { 48 | ctx := context.WithValue(r.Context(), "assets", html) 49 | next(w, r.WithContext(ctx)) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server/vite.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "grafikart/boilerplate/utils" 6 | "io/fs" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type viteManifestItem struct { 12 | File string `json:"file"` 13 | Name string `json:"name"` 14 | Src string `json:"src"` 15 | IsEntry bool `json:"isEntry"` 16 | CSS []string `json:"css"` 17 | } 18 | 19 | type viteManifestData map[string]viteManifestItem 20 | 21 | type ViteAssets struct { 22 | publicPath string 23 | assets fs.FS 24 | hasManifest bool 25 | port int16 26 | manifestData viteManifestData 27 | } 28 | 29 | func NewViteAssets(filesystem fs.FS) *ViteAssets { 30 | var data viteManifestData 31 | manifestPath := "assets/.vite/manifest.json" 32 | f, err := filesystem.Open(manifestPath) 33 | if err == nil { 34 | defer f.Close() 35 | err = utils.ParseJsonFile(f, &data) 36 | } 37 | return &ViteAssets{ 38 | publicPath: "/assets/", 39 | assets: filesystem, 40 | hasManifest: err == nil, 41 | port: 3000, 42 | manifestData: data, 43 | } 44 | } 45 | 46 | func (v ViteAssets) ServeAssets(w http.ResponseWriter, r *http.Request) { 47 | if v.hasManifest { 48 | http.FileServer(http.FS(v.assets)).ServeHTTP(w, r) 49 | return 50 | } 51 | 52 | // Proxy everything to vite in dev mode 53 | u := *r.URL 54 | u.Host = fmt.Sprintf("%s:%d", strings.Split(r.Host, ":")[0], v.port) 55 | w.Header().Set("Location", u.String()) 56 | w.WriteHeader(301) 57 | } 58 | 59 | func (v ViteAssets) GetHeadHTML() string { 60 | var sb strings.Builder 61 | if !v.hasManifest { 62 | sb.WriteString(fmt.Sprintf(` 63 | `, v.port)) 64 | return sb.String() 65 | } 66 | 67 | for _, item := range v.manifestData { 68 | sb.WriteString(fmt.Sprintf("", v.publicPath, item.File)) 69 | for _, css := range item.CSS { 70 | sb.WriteString(fmt.Sprintf("", v.publicPath, css)) 71 | } 72 | } 73 | 74 | return sb.String() 75 | } 76 | -------------------------------------------------------------------------------- /assets/css/reset.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | /*** 3 | The new CSS reset - version 1.8.5 (last updated 14.6.2023) 4 | GitHub page: https://github.com/elad2412/the-new-css-reset 5 | ***/ 6 | 7 | /* 8 | Remove all the styles of the "User-Agent-Stylesheet", except for the 'display' property 9 | - The "symbol *" part is to solve Firefox SVG sprite bug 10 | - The "html" attribute is exclud, because otherwise a bug in Chrome breaks the CSS hyphens property (https://github.com/elad2412/the-new-css-reset/issues/36) 11 | */ 12 | *:where( 13 | :not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *) 14 | ) { 15 | all: unset; 16 | display: revert; 17 | } 18 | 19 | /* Preferred box-sizing value */ 20 | *, 21 | *::before, 22 | *::after { 23 | box-sizing: border-box; 24 | } 25 | 26 | /* Reapply the pointer cursor for anchor tags */ 27 | a, 28 | button { 29 | cursor: revert; 30 | } 31 | 32 | /* Remove list styles (bullets/numbers) */ 33 | ol, 34 | ul, 35 | menu { 36 | list-style: none; 37 | } 38 | 39 | /* For images to not be able to exceed their container */ 40 | img { 41 | max-inline-size: 100%; 42 | max-block-size: 100%; 43 | } 44 | 45 | /* removes spacing between cells in tables */ 46 | table { 47 | border-collapse: collapse; 48 | } 49 | 50 | /* Safari - solving issue when using user-select:none on the text input doesn't working */ 51 | input, 52 | textarea { 53 | -webkit-user-select: auto; 54 | } 55 | 56 | /* revert the 'white-space' property for textarea elements on Safari */ 57 | textarea { 58 | white-space: revert; 59 | } 60 | 61 | /* minimum style to allow to style meter element */ 62 | meter { 63 | -webkit-appearance: revert; 64 | appearance: revert; 65 | } 66 | 67 | /* preformatted text - use only for this feature */ 68 | :where(pre) { 69 | all: revert; 70 | } 71 | 72 | /* reset default text opacity of input placeholder */ 73 | ::placeholder { 74 | color: unset; 75 | } 76 | 77 | /* remove default dot (•) sign */ 78 | ::marker { 79 | content: initial; 80 | } 81 | 82 | /* fix the feature of 'hidden' attribute. 83 | display:revert; revert to element instead of attribute */ 84 | :where([hidden]) { 85 | display: none; 86 | } 87 | 88 | /* revert for bug in Chromium browsers 89 | - fix for the content editable attribute will work properly. 90 | - webkit-user-select: auto; added for Safari in case of using user-select:none on wrapper element */ 91 | :where([contenteditable]:not([contenteditable="false"])) { 92 | -moz-user-modify: read-write; 93 | -webkit-user-modify: read-write; 94 | overflow-wrap: break-word; 95 | -webkit-line-break: after-white-space; 96 | -webkit-user-select: auto; 97 | } 98 | 99 | /* apply back the draggable feature - exist only in Chromium and Safari */ 100 | :where([draggable="true"]) { 101 | -webkit-user-drag: element; 102 | } 103 | 104 | /* Revert Modal native behavior */ 105 | :where(dialog:modal) { 106 | all: revert; 107 | } 108 | --------------------------------------------------------------------------------