Embedded the frontend in the binary, and built it in the build script

`npm run sync` in web/ copies the build output (web/dist, from npm run build)
to internal/frontend/dist, which the binary embeds. Go can't embed from
outside the package's directory (web/ is also outside the Go module), hence
the copy. Only dist/.gitkeep is committed, so a fresh clone still compiles,
and a binary built without syncing first serves no frontend.

job-search serve serves server.frontend.dir if it is set, or else the
embedded build, or warns that there is no frontend. scripts/build.sh builds
and syncs the frontend before building, running npm ci first for a release
or when web/node_modules is missing.
This commit is contained in:
bwilliams committed 2026-10-16 16:03:15 +01:00
1 parent 1832ea7437
commit 5e6c011ecb
12 files changed
+103 -10

No files matched your search

+5
View File
@@ -39,3 +39,8 @@ build/
release/ release/
mock-data/ mock-data/
# The frontend's build output, copied by npm run sync in web/ for embedding.
# .gitkeep is kept, as Go can't embed a missing directory.
internal/frontend/dist/*
!internal/frontend/dist/.gitkeep
+16 -1
View File
@@ -15,11 +15,24 @@ scripts/build.sh # build/job-search-<version>
scripts/build.sh --release # also package release/job-search-<version>-<arch>.tar.gz scripts/build.sh --release # also package release/job-search-<version>-<arch>.tar.gz
``` ```
The build script runs `go generate ./...`, which needs `swag` installed (see The build script builds the frontend and copies it into place for embedding
(`npm run build` and `npm run sync` in `web/`, which need Node and npm; it
runs `npm ci` first for a release, or when `web/node_modules` is missing). It
then runs `go generate ./...`, which needs `swag` installed (see
[Installation of dev tools](#installation-of-dev-tools)), and stamps the [Installation of dev tools](#installation-of-dev-tools)), and stamps the
version from the latest git tag. A build from untagged commits is marked as a development build version from the latest git tag. A build from untagged commits is marked as a development build
and isn't packaged unless `--force` is also given. and isn't packaged unless `--force` is also given.
The frontend (`web/`) is embedded in the binary. Without the build script,
build it and copy it into place first, or the binary serves no frontend
(`job-search serve` warns):
```bash
cd web && npm ci && npm run build && npm run sync # web/dist -> internal/frontend/dist
```
Setting `server.frontend.dir` serves a build from disk instead.
During development, `go run -tags sqlite_fts5 ./cmd/jobsearch` works as well. During development, `go run -tags sqlite_fts5 ./cmd/jobsearch` works as well.
The `sqlite_fts5` tag is needed for the database's title search (see The `sqlite_fts5` tag is needed for the database's title search (see
[Testing](#testing)). [Testing](#testing)).
@@ -157,6 +170,8 @@ second one fails with "address already in use".
| `internal/logging` | slog setup and the HTTP access log | | `internal/logging` | slog setup and the HTTP access log |
| `internal/mock` | Mock external services | | `internal/mock` | Mock external services |
| `internal/version` | Version information set at build time | | `internal/version` | Version information set at build time |
| `internal/frontend` | Serves the frontend's build output; embeds `dist/`, copied there from `web/dist` by `npm run sync` |
| `web` | The frontend (see [web](web/README.md)) |
## Testing ## Testing
+15 -4
View File
@@ -31,7 +31,9 @@ func NewCommand(opts *cli.Options) *cobra.Command {
Use: "serve", Use: "serve",
Short: "Serve the REST API, WebSocket and frontend, and fetch from sources", Short: "Serve the REST API, WebSocket and frontend, and fetch from sources",
Long: `Serve the REST API under /api, the WebSocket endpoint under /ws, and the Long: `Serve the REST API under /api, the WebSocket endpoint under /ws, and the
frontend's files from server.frontend.dir under / if it is set. frontend under /: the files in server.frontend.dir if it is set, or else the
build embedded in the binary (copied there by npm run sync in web/ before
building), if there is one.
It also runs a worker for every enabled source, as fetcher service does, It also runs a worker for every enabled source, as fetcher service does,
saving what they fetch to database.file if it is set, and pushing an saving what they fetch to database.file if it is set, and pushing an
@@ -196,9 +198,10 @@ set, the API serves the stored opportunities under /api/opportunities.`,
} }
// serverOptions returns the server's options for conf: the access log, if // serverOptions returns the server's options for conf: the access log, if
// server.access_log is set, and the frontend's files, if server.frontend.dir // server.access_log is set, and the frontend's files: those in
// is (checked first). closeAccessLog closes the access log; call it once the // server.frontend.dir if it is set (checked first), or else the build
// server has stopped. // embedded in the binary, if there is one. closeAccessLog closes the access
// log; call it once the server has stopped.
func serverOptions( func serverOptions(
conf *config.JSCServer, conf *config.JSCServer,
) ([]server.Option, func() error, error) { ) ([]server.Option, func() error, error) {
@@ -225,7 +228,15 @@ func serverOptions(
) )
} }
options = append(options, server.WithFrontend(files)) options = append(options, server.WithFrontend(files))
return options, closeAccessLog, nil
} }
files := frontend.Embedded()
if files == nil {
slog.Warn("no frontend embedded and server.frontend.dir not set")
return options, closeAccessLog, nil
}
options = append(options, server.WithFrontend(files))
return options, closeAccessLog, nil return options, closeAccessLog, nil
} }
+1 -1
View File
@@ -179,7 +179,7 @@ The `server` section is split into a number of sub-sections:
| Key | Type | Default | Notes | | Key | Type | Default | Notes |
|---|---|---|---| |---|---|---|---|
| `server.frontend.dir` | string | | Directory of the frontend's build output, holding `index.html`; empty: no frontend is served (there is no embedded build yet) | | `server.frontend.dir` | string | | Directory of the frontend's build output, holding `index.html`; empty: the build embedded in the binary (see `npm run sync` in `web/`), or no frontend if it has none |
| `server.frontend.immutable_prefix` | string | | Where Vite emits content-hashed files, cached for a year; empty: `/assets/` | | `server.frontend.immutable_prefix` | string | | Where Vite emits content-hashed files, cached for a year; empty: `/assets/` |
| `server.frontend.index_cache_control` | string | | `Cache-Control` of HTML and other files without a hash; empty: `no-cache` | | `server.frontend.index_cache_control` | string | | `Cache-Control` of HTML and other files without a hash; empty: `no-cache` |
+1 -1
View File
@@ -9,7 +9,7 @@ const JSCKServerFrontend string = "frontend"
// JSCServerFrontend configures how the server serves the frontend. Dir is // JSCServerFrontend configures how the server serves the frontend. Dir is
// the directory of the frontend's build output (holding index.html); empty // the directory of the frontend's build output (holding index.html); empty
// means no frontend is served, as there is no embedded build yet. // means the build embedded in the binary, or no frontend if it has none.
// ImmutablePrefix is where Vite emits content-hashed files, cached for a // ImmutablePrefix is where Vite emits content-hashed files, cached for a
// year; empty means "/assets/". IndexCacheControl is the Cache-Control of // year; empty means "/assets/". IndexCacheControl is the Cache-Control of
// HTML and other files without a hash; empty means "no-cache". // HTML and other files without a hash; empty means "no-cache".
View File
Whitespace-only changes.
+24
View File
@@ -0,0 +1,24 @@
package frontend
import (
"embed"
"io/fs"
)
// dist holds the frontend's build output, copied from web/dist by `npm run
// sync` in web/. Only dist/.gitkeep is committed (Go can't embed a missing
// directory), so a binary built without syncing first has no frontend.
//
//go:embed all:dist
var dist embed.FS
// Embedded returns the frontend's build output embedded in the binary, or
// nil if none was synced before it was built (there is no index.html).
func Embedded() fs.FS {
files, err := fs.Sub(dist, "dist")
if err != nil || CheckFS(files) != nil {
return nil
}
return files
}
+8
View File
@@ -39,6 +39,14 @@ if [ "$GIT_COMMIT" != "" ]; then
LD_FLAGS="$LD_FLAGS -X git.avsdev.uk/Go/job-search/internal/version.GitCommit=${GIT_COMMIT}" LD_FLAGS="$LD_FLAGS -X git.avsdev.uk/Go/job-search/internal/version.GitCommit=${GIT_COMMIT}"
fi fi
# Build the frontend and copy it to internal/frontend/dist, where the binary
# embeds it. npm ci installs exactly the lock file's versions: always for a
# release, otherwise only when nothing is installed yet.
if [[ $DO_RELEASE -eq 1 || ! -d web/node_modules ]]; then
(cd web && npm ci)
fi
(cd web && npm run build && npm run sync)
# Process any generators # Process any generators
go generate ./... go generate ./...
+3 -2
View File
@@ -15,7 +15,8 @@ npm run lint # eslint .
npm run format:check # prettier --check . npm run format:check # prettier --check .
npm run format # prettier --write .: formats every file npm run format # prettier --write .: formats every file
npm run generate:api # regenerate src/types/api.ts from the backend's OpenAPI description npm run generate:api # regenerate src/types/api.ts from the backend's OpenAPI description
npm run build # typecheck, then vite build npm run build # typecheck, then vite build into dist/
npm run sync # copy dist/ to ../internal/frontend/dist/, which the backend embeds
npm run dev # dev server; /api and /ws go to the backend (API_PROXY_TARGET, default localhost:9393) npm run dev # dev server; /api and /ws go to the backend (API_PROXY_TARGET, default localhost:9393)
``` ```
@@ -79,7 +80,7 @@ if (isRelease(parsed)) { ... }
| `src/primitives/` | Solid primitives: state and loading, no markup (`createX.ts`) | | `src/primitives/` | Solid primitives: state and loading, no markup (`createX.ts`) |
| `src/components/` | Components, one exported component per file | | `src/components/` | Components, one exported component per file |
| `src/lib/` | Plain functions: no Solid, no DOM where avoidable | | `src/lib/` | Plain functions: no Solid, no DOM where avoidable |
| `scripts/` | Development scripts run by npm, in TypeScript (`generateApiTypes.ts`) | | `scripts/` | Development scripts run by npm, in TypeScript (`generateApiTypes.ts`, `syncDist.ts`) |
- Components' files are PascalCase, named after the component - Components' files are PascalCase, named after the component
(`MarkMenu.tsx`). Other files are usually camelCase (`format.ts`); the (`MarkMenu.tsx`). Other files are usually camelCase (`format.ts`); the
+4
View File
@@ -14,6 +14,10 @@ default address (`http://localhost:9393`). To use another, copy `.env.example`
to `.env` and set `API_PROXY_TARGET`. `VITE_API_BASE_URL` changes the `/api` to `.env` and set `API_PROXY_TARGET`. `VITE_API_BASE_URL` changes the `/api`
prefix for production builds. prefix for production builds.
`npm run build` type-checks and builds the app into `dist/`. `npm run sync`
then copies `dist/` to `../internal/frontend/dist/`, where the backend embeds
it: the next Go build of `job-search` serves it. The copy isn't committed.
Before committing, `npm run check` type-checks, lints (ESLint) and checks Before committing, `npm run check` type-checks, lints (ESLint) and checks
formatting (Prettier); `npm run format` formats everything. See formatting (Prettier); `npm run format` formats everything. See
[CODE_CONVENTIONS.md](CODE_CONVENTIONS.md). [CODE_CONVENTIONS.md](CODE_CONVENTIONS.md).
+2 -1
View File
@@ -12,7 +12,8 @@
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"check": "npm run typecheck && npm run lint && npm run format:check", "check": "npm run typecheck && npm run lint && npm run format:check",
"generate:api": "node scripts/generateApiTypes.ts" "generate:api": "node scripts/generateApiTypes.ts",
"sync": "node scripts/syncDist.ts"
}, },
"dependencies": { "dependencies": {
"solid-js": "^1.9.3" "solid-js": "^1.9.3"
+24
View File
@@ -0,0 +1,24 @@
// Copies the frontend's build output, dist/ (from npm run build), to
// ../internal/frontend/dist/, which the backend embeds, so the next Go build
// of job-search serves this frontend:
//
// npm run build && npm run sync
//
// The old copy is replaced entirely, so files from an earlier build don't
// linger. Its .gitkeep, the only file there that's committed, is put back:
// Go can't embed a directory that doesn't exist.
import { cpSync, existsSync, rmSync, writeFileSync } from 'node:fs';
const source = 'dist';
const target = '../internal/frontend/dist';
if (!existsSync(`${source}/index.html`)) {
console.error(`${source}/index.html not found: run npm run build first`);
process.exit(1);
}
rmSync(target, { recursive: true, force: true });
cpSync(source, target, { recursive: true });
writeFileSync(`${target}/.gitkeep`, '');
console.log(`Copied ${source}/ to ${target}/`);