├── 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 |
--------------------------------------------------------------------------------