feat(build): integrate frontend build into backend binary and update scripts

This commit is contained in:
2026-09-22 22:57:08 +08:00
parent 15144fb9d4
commit 4be6d2add0
15 changed files with 175 additions and 109 deletions
+9
View File
@@ -0,0 +1,9 @@
* text=auto
# cmd.exe misparses a batch file whose lines end in a bare LF: it loses
# characters at the start of later lines, so a working script silently turns
# into "command not recognized" errors. Force CRLF on checkout.
*.bat text eol=crlf
# The mirror image: a CR at the end of a shebang or line breaks these.
*.sh text eol=lf
+3 -14
View File
@@ -48,10 +48,10 @@ jobs:
cache: npm
cache-dependency-path: frontend/package-lock.json
# --frontend builds the Vue app inside the script. The second call omits
# it: the frontend is platform independent and dist/ is already built.
# Each script builds the Vue console itself and then compiles it into the
# binary (web/embed.go), so the uploaded executables are self-contained.
- name: Build Linux (amd64)
run: ${{ matrix.build_linux }} --frontend
run: ${{ matrix.build_linux }}
- name: Build Windows (amd64)
run: ${{ matrix.build_windows }}
@@ -64,14 +64,3 @@ jobs:
nukumizu-linux-amd64
nukumizu-windows-amd64.exe
if-no-files-found: error
# The binaries read frontend/dist at runtime (os.DirFS in web/embed.go),
# they do not embed it. Uploaded once — the frontend is platform
# independent, so both matrix legs produce the same files.
- name: Upload frontend bundle
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: nukumizu-frontend
path: frontend/dist
if-no-files-found: error
+5
View File
@@ -6,6 +6,11 @@ bot_node_config.json
*.exe
nukumizu-linux-amd64
# The built web console. web/embed.go compiles it into the binary, and the
# build-*.sh / build-*.bat scripts rebuild it before every compile.
/web/dist/
/frontend/dist/
# SQLite database files. They live on a network share (Y:), and git/cloud
# sync touching them while a WAL database is open corrupts the WAL index and
# crashes the process (EXCEPTION_IN_PAGE_ERROR). Never track these.
+13 -12
View File
@@ -15,7 +15,7 @@ Nukumizu connects to a Komari Dashboard instance, keeps an in-memory view of eve
- **Customizable message templates** — every bot/notification message is rendered from a template in `config.json`.
- **Storage** — SQLite (pure-Go driver) for `user.db` and `log.db`; safe on network shares (WAL disabled).
- **Dashboard API** — token-authenticated REST API plus a live log-streaming WebSocket.
- **Web console** — a Vue 3 admin UI for browsing nodes, editing `config.json`, managing bot trust, and tailing logs. The Go server serves the built bundle from `frontend/dist`.
- **Web console** — a Vue 3 admin UI for browsing nodes, editing `config.json`, managing bot trust, and tailing logs. The built bundle is embedded in the binary, so a single executable serves both the API and the console.
## How it works
@@ -52,7 +52,8 @@ nukumizu-backend/
│ ├── logBroadcaster.go # Fan-out to WebSocket clients
│ └── logSocketHandler.go # /api/system/getLogs WebSocket handler
├── web/
│ ├── embed.go # Locates the built console (frontend/dist)
│ ├── embed.go # Embeds the built console (web/dist) in the binary
│ ├── dist/ # Vite build output — generated, gitignored
│ └── handler.go # Static file serving + SPA fallback
├── internal/
│ ├── komari/
@@ -344,7 +345,7 @@ QQ and Telegram are *interactive* channels. Email, ntfy, and webhook are **statu
Requires Go 1.25+ and — to build the web console — Node.js 22+.
Helper scripts in the repo root bake the current git commit and build time into the binary via `-ldflags`. The name states the **target** platform, and each target has a Windows (`.bat`) and a Linux/macOS (`.sh`) flavor: run the flavor for the host you are building on, since every script cross-compiles to its target.
Helper scripts in the repo root build the Vue console first, then compile the backend with it embedded. They also bake the current git commit and build time into the binary via `-ldflags`. The name states the **target** platform, and each target has a Windows (`.bat`) and a Linux/macOS (`.sh`) flavor: run the flavor for the host you are building on, since every script cross-compiles to its target.
| Script | Output |
|---|---|
@@ -361,17 +362,17 @@ build-linux-x86_64.bat
build-win-x86_64.bat
```
Pass `--frontend` to build the Vue console first (`npm ci` + `npm run build` inside `frontend/`); without it only the backend is compiled:
The console is **embedded in the binary**. Vite writes it to `web/dist` and `web/embed.go` compiles that directory in with `go:embed`, so the executable serves the whole frontend on its own — copy it anywhere, with neither `frontend/` nor `web/dist` next to it, and `/` still returns the console. The build scripts run `npm ci` when `frontend/node_modules` is missing and `npm run build` on every run, so they need Node.js 22+ on the build machine (not on the machine that runs the binary).
```bash
./build-linux-x86_64.sh --frontend
```
The console is **not embedded in the binary** — `web/` reads `frontend/dist` from disk at runtime. A binary built without `--frontend` still starts and serves the API, but `/` answers `500 index.html not found` until a built `frontend/dist` sits in the working directory. Build it once with `--frontend`, then re-run any of the four scripts without the flag.
Building the backend therefore requires the console to have been built at least once: `web/dist` is a generated, gitignored directory, and `go build` fails with `pattern all:dist: no matching files found` until it exists. Any `build-*` script handles that ordering for you.
Equivalent manual builds:
```bash
# 1. Console (once per frontend change)
cd frontend && npm ci && npm run build && cd ..
# 2. Backend
# Linux / macOS
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags "-X main.CommitHash=$(git rev-parse --short HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
@@ -385,7 +386,7 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
`main.CommitHash` and `main.BuildTime` are surfaced in logs and in the `BOT_STARTED` message.
CI (`.github/workflows/build.yml`) runs both flavors — `.sh` on `ubuntu-latest`, `.bat` on `windows-latest` — and uploads the two binaries plus the frontend bundle as artifacts.
CI (`.github/workflows/build.yml`) runs both flavors — `.sh` on `ubuntu-latest`, `.bat` on `windows-latest` — and uploads the two self-contained binaries as artifacts.
## Running
@@ -403,7 +404,7 @@ On startup the program logs in to Komari, loads node state, connects the status
### Frontend development
`run.bat` only runs the Go backend — it does not build the console. While working on the frontend, run the two sides separately:
`run.bat` only runs the Go backend — it does not build the console, so `web/dist` must already exist (run any `build-*` script once, or `npm run build` in `frontend/`) or `go run` fails to compile. While working on the frontend, run the two sides separately:
```bash
# Terminal 1 — backend (API + WebSocket) on :8080
@@ -421,7 +422,7 @@ Open http://localhost:5173. The dev server proxies `/api` — including the log
NUKUMIZU_API=http://192.168.1.10:8080 npm run dev
```
For a production build the Go server serves `frontend/dist` itself, on the normal listen address — see [Building](#building).
For a production build the Go server serves the embedded console itself, on the normal listen address — see [Building](#building). Vite is not involved at runtime, so `npm run build` alone does not change what a running binary serves: rebuild the binary to pick up frontend changes.
## License
+22 -10
View File
@@ -4,21 +4,33 @@ setlocal enabledelayedexpansion
:: Build from the repository root, however the script was invoked.
cd /d "%~dp0"
:: Optional first argument: --frontend also builds the Vue frontend, which web/
:: serves at runtime from frontend/dist. Omitted, only the backend is compiled.
set BUILD_FRONTEND=0
if /i "%~1"=="--frontend" set BUILD_FRONTEND=1
if not "%BUILD_FRONTEND%"=="1" goto :backend
:: The Vue console is built first and embedded into the binary (web\dist, see
:: web\embed.go), so the executable serves the whole frontend on its own:
:: neither frontend\ nor web\dist\ is needed where it runs.
echo Building frontend...
cd frontend
call npm ci
if errorlevel 1 goto :fail
:: node_modules is gitignored, so a fresh checkout (CI included) installs from
:: the lockfile; a warm tree only rebuilds.
if not exist "node_modules" (
call npm ci
if errorlevel 1 goto :frontend_failed
)
call npm run build
if errorlevel 1 goto :fail
if errorlevel 1 goto :frontend_failed
cd ..
:: go:embed on web\dist fails anyway, but this names the real problem.
if exist "web\dist\index.html" goto :backend
echo Frontend build produced no web\dist\index.html.
goto :fail
:frontend_failed
cd ..
echo Frontend build failed.
goto :fail
:backend
echo Building for Linux (amd64)...
+17 -14
View File
@@ -1,27 +1,30 @@
#!/usr/bin/env bash
#
# Builds the Linux (amd64) binary.
# ./build-linux-x86_64.sh backend only
# ./build-linux-x86_64.sh --frontend also build the Vue frontend
#
# The frontend is needed at runtime: web/ serves it from frontend/dist.
# The Vue console is built first and embedded into the binary (web/dist, see
# web/embed.go), so the executable serves the whole frontend on its own —
# neither frontend/ nor web/dist/ is needed where it runs.
set -euo pipefail
# Build from the repository root, however the script was invoked.
cd "$(dirname "$0")"
BUILD_FRONTEND=0
if [ "${1:-}" = "--frontend" ]; then
BUILD_FRONTEND=1
fi
if [ "$BUILD_FRONTEND" = "1" ]; then
echo "Building frontend..."
(
cd frontend
echo "Building frontend..."
(
cd frontend
# node_modules is gitignored, so a fresh checkout (CI included) installs
# from the lockfile; a warm tree only rebuilds.
if [ ! -d node_modules ]; then
npm ci
npm run build
)
fi
npm run build
)
# go:embed on web/dist fails anyway, but this names the real problem.
if [ ! -f web/dist/index.html ]; then
echo "Frontend build produced no web/dist/index.html" >&2
exit 1
fi
echo "Building for Linux (amd64)..."
+22 -10
View File
@@ -4,21 +4,33 @@ setlocal enabledelayedexpansion
:: Build from the repository root, however the script was invoked.
cd /d "%~dp0"
:: Optional first argument: --frontend also builds the Vue frontend, which web/
:: serves at runtime from frontend/dist. Omitted, only the backend is compiled.
set BUILD_FRONTEND=0
if /i "%~1"=="--frontend" set BUILD_FRONTEND=1
if not "%BUILD_FRONTEND%"=="1" goto :backend
:: The Vue console is built first and embedded into the binary (web\dist, see
:: web\embed.go), so the executable serves the whole frontend on its own:
:: neither frontend\ nor web\dist\ is needed where it runs.
echo Building frontend...
cd frontend
call npm ci
if errorlevel 1 goto :fail
:: node_modules is gitignored, so a fresh checkout (CI included) installs from
:: the lockfile; a warm tree only rebuilds.
if not exist "node_modules" (
call npm ci
if errorlevel 1 goto :frontend_failed
)
call npm run build
if errorlevel 1 goto :fail
if errorlevel 1 goto :frontend_failed
cd ..
:: go:embed on web\dist fails anyway, but this names the real problem.
if exist "web\dist\index.html" goto :backend
echo Frontend build produced no web\dist\index.html.
goto :fail
:frontend_failed
cd ..
echo Frontend build failed.
goto :fail
:backend
echo Building for Windows (amd64)...
+17 -14
View File
@@ -1,27 +1,30 @@
#!/usr/bin/env bash
#
# Builds the Windows (amd64) binary.
# ./build-win-x86_64.sh backend only
# ./build-win-x86_64.sh --frontend also build the Vue frontend
#
# The frontend is needed at runtime: web/ serves it from frontend/dist.
# The Vue console is built first and embedded into the binary (web/dist, see
# web/embed.go), so the executable serves the whole frontend on its own —
# neither frontend/ nor web/dist/ is needed where it runs.
set -euo pipefail
# Build from the repository root, however the script was invoked.
cd "$(dirname "$0")"
BUILD_FRONTEND=0
if [ "${1:-}" = "--frontend" ]; then
BUILD_FRONTEND=1
fi
if [ "$BUILD_FRONTEND" = "1" ]; then
echo "Building frontend..."
(
cd frontend
echo "Building frontend..."
(
cd frontend
# node_modules is gitignored, so a fresh checkout (CI included) installs
# from the lockfile; a warm tree only rebuilds.
if [ ! -d node_modules ]; then
npm ci
npm run build
)
fi
npm run build
)
# go:embed on web/dist fails anyway, but this names the real problem.
if [ ! -f web/dist/index.html ]; then
echo "Frontend build produced no web/dist/index.html" >&2
exit 1
fi
echo "Building for Windows (amd64)..."
+4
View File
@@ -1,3 +1,7 @@
## Ver.0.1.2.4
### Bug Fixes
- [Integrate frontend build into backend binary]("")
## Ver.0.1.2.3-1c4ad61.pre-release
### Features
- [Add frontend webpage to make everything easy to control]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/a90b4f5497dfae5f9b6a3132d091a528fdbdf612")
-1
View File
@@ -1,4 +1,3 @@
node_modules/
dist/
*.local
.DS_Store
+3 -3
View File
@@ -24,14 +24,14 @@ The dev server proxies `/api` (and the log websocket) to the backend. By default
$env:NUKUMIZU_API = "http://192.168.20.4:8080"; npm run dev
```
Production build (static assets only):
Production build:
```bash
npm run build # outputs dist/
npm run build # outputs ../web/dist
npm run preview
```
The backend does not serve static files, so put `dist/` behind any static server and proxy `/api` (and `ws://…/api/system/getLogs`) to the Nukumizu backend.
Vite writes to `../web/dist` rather than `frontend/dist` (see `build.outDir` in `vite.config.js`) because the Go backend embeds that directory into the binary — `go:embed` cannot reach outside the package it sits in, so the output has to live under `web/`. The repo's `build-*` scripts run this build for you and compile the backend afterwards; `npm run build` alone does not change what an already-built binary serves.
## API contract notes
+10
View File
@@ -6,6 +6,16 @@ const BACKEND = process.env.NUKUMIZU_API || 'http://127.0.0.1:8080';
export default defineConfig({
plugins: [vue()],
build: {
// web/embed.go embeds this directory into the Go binary. It has to live
// inside the web package: go:embed cannot reach outside its own
// directory, so ../web/dist is as close as it gets.
outDir: '../web/dist',
// The directory is outside the project root, so Vite refuses to empty
// it unless told to. Without this, stale hashed assets from earlier
// builds pile up in the binary.
emptyOutDir: true
},
server: {
host: '0.0.0.0',
port: 5173,
+19 -11
View File
@@ -1,12 +1,20 @@
@echo off
setlocal enabledelayedexpansion
:: Get git commit hash (shortened to 7 characters, can also use full)
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
:: Get UTC time
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
:: Build -ldflags
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
@echo off
setlocal enabledelayedexpansion
:: The console is embedded in the binary (web\embed.go), so compiling without
:: web\dist fails. Say that plainly rather than leaving go:embed's error.
if not exist "web\dist\index.html" (
echo The web console is not built: web\dist is missing.
echo Run build-win-x86_64.bat once, or "npm run build" in frontend\.
exit /b 1
)
:: Get git commit hash (shortened to 7 characters, can also use full)
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
:: Get UTC time
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
:: Build -ldflags
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
go run -ldflags "%LDFLAGS%" .
+24 -12
View File
@@ -1,23 +1,35 @@
package web
import (
"embed"
"io/fs"
"os"
"path/filepath"
)
// StaticFiles is the built frontend, rooted at the directory Vite writes to
// (frontend/dist), so paths inside it are relative to that directory, e.g.
// "index.html" or "assets/app.js". The path is relative to the working
// directory, so the server is expected to run from the repository root.
// distFS holds the built console. Vite writes it to web/dist (see the outDir
// in frontend/vite.config.js) and it is compiled into the binary here, so a
// running executable serves the whole frontend on its own: neither the
// frontend sources nor web/dist need to exist on the machine that runs it.
//
// The all: prefix also picks up files whose names start with "_" or ".", which
// the default pattern skips.
//
//go:embed all:dist
var distFS embed.FS
// StaticFiles is the built frontend, rooted at the directory Vite writes to,
// so paths inside it are relative to that directory, e.g. "index.html" or
// "assets/app.js".
//
// web/dist is a build artifact and is not in a fresh checkout, so the console
// has to be built before the backend compiles — any build-*.sh / build-*.bat
// does it first, or run `npm run build` in frontend/ yourself. Compiling
// without it fails with "pattern all:dist: no matching files found".
var StaticFiles fs.FS
func init() {
dir := filepath.Join("frontend", "dist")
if _, err := os.Stat(dir); err == nil {
StaticFiles = os.DirFS(dir)
return
sub, err := fs.Sub(distFS, "dist")
if err != nil {
panic("web: embedded frontend is unreadable: " + err.Error())
}
StaticFiles = os.DirFS(".")
StaticFiles = sub
}
+7 -8
View File
@@ -7,13 +7,12 @@ import (
"strings"
)
var staticFS http.FileSystem
// This init runs after embed.go's, which sets StaticFiles (Go initializes a
// package's files in lexical file-name order). StaticFiles is already rooted
// at frontend/dist, so it is served as-is — no further fs.Sub is needed.
func init() {
staticFS = http.FS(StaticFiles)
// staticFS wraps the embedded frontend for net/http. StaticFiles is already
// rooted at the directory Vite writes to, so it is served as-is — no further
// fs.Sub is needed. http.FS copies a file that is not an io.Seeker into memory
// before serving it, which keeps this working whatever fs.FS StaticFiles is.
func staticFS() http.FileSystem {
return http.FS(StaticFiles)
}
func ServeStatic(w http.ResponseWriter, r *http.Request) {
@@ -25,7 +24,7 @@ func ServeStatic(w http.ResponseWriter, r *http.Request) {
}
filePath := strings.TrimPrefix(urlPath, "/")
f, err := staticFS.Open(filePath)
f, err := staticFS().Open(filePath)
if err != nil {
serveIndexHTML(w)
return