diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index a16ddc3..7875507 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -10,3 +10,23 @@ updates:
github-actions:
patterns:
- "*"
+
+ # Go module dependencies.
+ - package-ecosystem: gomod
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ gomod:
+ patterns:
+ - "*"
+
+ # Frontend (npm) dependencies.
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ npm:
+ patterns:
+ - "*"
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 5989832..fdfb661 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -3,11 +3,12 @@ on:
push:
branches:
- main
- # Release flavor is derived from the tag suffix in the deploy job:
+ # Release flavor is derived from the tag suffix in the deploy job. Convention: prefix tags
+ # with "v" (bare X.Y.Z is still accepted for backward compatibility).
tags:
- - '*.*.*' # stable semver (e.g. 1.2.3): published and marked "Latest"
- - '*-preview' # pre-release (e.g. 1.2.3-preview): published, never "Latest"
- - '*-draft' # draft (e.g. 1.2.3-draft): unpublished for review, never "Latest"
+ - '*.*.*' # stable semver (e.g. v1.2.3): published and marked "Latest"
+ - '*-preview' # pre-release (e.g. v1.2.3-preview): published, never "Latest"
+ - '*-draft' # draft (e.g. v1.2.3-draft): unpublished for review, never "Latest"
pull_request:
branches:
- main
@@ -108,6 +109,9 @@ jobs:
make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} build-docker
make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} start-docker
+ - name: Smoke-test the docker image
+ run: make VERSION=${{ steps.extract_ref.outputs.GIT_REF }} smoke-docker
+
- if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
diff --git a/.gitignore b/.gitignore
index 893be12..219d6f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@
node_modules/
# All build, test and runtime output goes here (see Makefile / vite.config.ts / playwright.config.ts).
build/
+# The embedded client is copied here by the release build; keep only the placeholder in git.
+server/frontend/dist/*
+!server/frontend/dist/.gitkeep
diff --git a/Dockerfile b/Dockerfile
index 5084dd0..e65b20b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
ARG GO_VERSION=1.26
FROM golang:${GO_VERSION}-alpine AS build-backend
-RUN apk add --no-cache make
+RUN apk add --no-cache make ca-certificates
ARG VERSION=snapshot
ARG COMMIT
WORKDIR /go/src/smocker
@@ -8,12 +8,19 @@ COPY go.mod go.sum ./
RUN go mod download
COPY Makefile main.go ./
COPY server/ ./server/
-RUN make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build
+# The client is built on the host (make release); bake it into the binary via go:embed.
+COPY build/client ./server/frontend/dist/
+# CGO_ENABLED=0 produces a fully static binary (pure-Go net/tls), required for a scratch image.
+RUN CGO_ENABLED=0 make VERSION=$VERSION COMMIT=$COMMIT RELEASE=1 build
-FROM alpine
+FROM scratch
LABEL org.opencontainers.image.source="https://github.com/smocker-dev/smocker"
-WORKDIR /opt
EXPOSE 8080 8081
-COPY build/client client/
-COPY --from=build-backend /go/src/smocker/build/* /opt/
-CMD ["/opt/smocker"]
+# CA roots so the proxy mock type can reach HTTPS backends (scratch ships none).
+COPY --from=build-backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
+# The UI is embedded in the binary, so nothing else is needed — just the static binary.
+COPY --from=build-backend /go/src/smocker/build/smocker /smocker
+# Run unprivileged (nobody). The listen ports are >1024 so no privilege is needed; a mounted
+# persistence directory must be writable by this uid.
+USER 65534:65534
+ENTRYPOINT ["/smocker"]
diff --git a/Makefile b/Makefile
index 8bf7dd0..5670b2f 100644
--- a/Makefile
+++ b/Makefile
@@ -21,7 +21,7 @@ DOCKER_IMAGE=ghcr.io/smocker-dev/smocker
# A tag name must be valid ASCII and may contain lowercase and uppercase letters, digits, underscores, periods and dashes.
# A tag name may not start with a period or a dash and may contain a maximum of 128 characters.
DOCKER_TAG:=$(shell echo $(VERSION) | tr -cd '[:alnum:]_.-')
-IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$")
+IS_SEMVER:=$(shell echo $(DOCKER_TAG) | grep -E "^v?[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$$")
LEVEL=debug
@@ -155,10 +155,26 @@ build-docker:
start-docker: check-default-ports
docker run -d -p 8080:8080 -p 8081:8081 --name $(APPNAME) $(DOCKER_IMAGE):$(DOCKER_TAG)
+# Smoke-test the running container (started by start-docker): the config API answers, the UI is
+# served (proves the embedded client works — the image ships no client directory), and a mock
+# registered on the config port is served on the mock port.
+.PHONY: smoke-docker
+smoke-docker:
+ @for i in $$(seq 1 30); do curl -sf http://localhost:8081/version >/dev/null 2>&1 && break; sleep 1; done
+ @curl -sf http://localhost:8081/version >/dev/null || { echo "FAIL: /version"; exit 1; }
+ @curl -sf http://localhost:8081/ | grep -q Smocker || { echo "FAIL: embedded UI not served"; exit 1; }
+ @curl -sf -XPOST http://localhost:8081/mocks -H "Content-Type: application/json" \
+ --data '[{"request":{"method":"GET","path":"/smoke"},"response":{"status":200,"body":"ok"}}]' >/dev/null \
+ || { echo "FAIL: register mock"; exit 1; }
+ @test "$$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/smoke)" = "200" \
+ || { echo "FAIL: mock round-trip"; exit 1; }
+ @echo "docker smoke tests passed"
+
.PHONY: check-default-ports
check-default-ports:
@lsof -i:8080 > /dev/null && (echo "Port 8080 already in use"; exit 1) || true
@lsof -i:8081 > /dev/null && (echo "Port 8081 already in use"; exit 1) || true
+ @lsof -i:$(PROXY_TARGET_PORT) > /dev/null && (echo "Port $(PROXY_TARGET_PORT) (go-httpbin) already in use"; exit 1) || true
.PHONY: optimize
optimize:
@@ -168,11 +184,15 @@ optimize:
# The following targets are only available for CI usage
build/smocker.tar.gz:
- $(MAKE) build
+ # Build the client first, copy it into the embed source, then build the Go binary so the UI
+ # is baked in (go:embed). The result is a self-contained binary — no client dir to ship.
npm ci --ignore-scripts
npm run build
- # Package only the release artifacts, not test/runtime output that may also live in build/.
- cd build/; tar -czvf smocker.tar.gz smocker client
+ rm -rf server/frontend/dist && mkdir -p server/frontend/dist
+ cp -r build/client/. server/frontend/dist/
+ touch server/frontend/dist/.gitkeep
+ $(MAKE) build
+ cd build/; tar -czvf smocker.tar.gz smocker
.PHONY: release
release: build/smocker.tar.gz
diff --git a/README.md b/README.md
index 861353b..0beee1d 100644
--- a/README.md
+++ b/README.md
@@ -167,15 +167,15 @@ The backend is written in Go. You can use the following commands to manage the d
### Frontend
-The frontend is written with TypeScript and React. You can use the following commands to manage the development lifecycle:
-
-- `yarn install`: install the dependencies
-- `yarn start`: start the frontend in development mode, with live reload
-- `yarn build`: generate the transpiled and minified files and assets
-- `yarn lint`: run static analysis on the code
-- `yarn format`: automatically format the frontend code
-- `yarn test`: execute unit tests
-- `yarn test:watch`: execute unit tests, with live reload
+The frontend is written with TypeScript and React, bundled with Vite. You can use the following commands to manage the development lifecycle:
+
+- `npm install`: install the dependencies
+- `npm run dev`: start the Vite dev server with hot reload. It proxies the admin API to the backend, so run `make start` alongside it (override the target with `SMOCKER_DEV_PROXY` if the backend is elsewhere)
+- `npm run build`: generate the transpiled and minified files and assets
+- `npm run lint`: run static analysis on the code
+- `npm run format`: automatically format the frontend code
+- `npm test`: execute unit tests
+- `npm run test:watch`: execute unit tests, with live reload
### Docker
diff --git a/client/modules/utils.test.ts b/client/modules/utils.test.ts
index f66f5f7..93d2a36 100644
--- a/client/modules/utils.test.ts
+++ b/client/modules/utils.test.ts
@@ -115,8 +115,7 @@ describe("Generate curl command from:", () => {
},
};
expect(entryToCurl(entry)).toBe(
- // FIXME: we shouldn't have the " if the client sent raw text
- `curl -XPOST '/test' --data '"value containing \\'single quotes\\'"'`,
+ `curl -XPOST '/test' --data 'value containing \\'single quotes\\''`,
);
});
});
diff --git a/client/modules/utils.ts b/client/modules/utils.ts
index 739f6ed..c7d8c87 100644
--- a/client/modules/utils.ts
+++ b/client/modules/utils.ts
@@ -60,7 +60,13 @@ export const entryToCurl = (historyEntry: Entry): string => {
);
if (request.body) {
- command.push(`--data '${escapeQuote(JSON.stringify(request.body))}'`);
+ // A raw/urlencoded body is already a string; only JSON bodies need serializing. Stringifying
+ // a string would wrap it in extra quotes (--data '"a=b"').
+ const data =
+ typeof request.body === "string"
+ ? request.body
+ : JSON.stringify(request.body);
+ command.push(`--data '${escapeQuote(data)}'`);
}
return command.join(" ");
diff --git a/server/admin_server.go b/server/admin_server.go
index 8417516..1c3851c 100644
--- a/server/admin_server.go
+++ b/server/admin_server.go
@@ -18,6 +18,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/smocker-dev/smocker/server/config"
+ "github.com/smocker-dev/smocker/server/frontend"
"github.com/smocker-dev/smocker/server/handlers"
"github.com/smocker-dev/smocker/server/services"
"golang.org/x/sync/errgroup"
@@ -79,8 +80,14 @@ func Serve(config config.Config) {
return c.JSON(http.StatusOK, config.Build)
})
- // UI Routes
- adminServerEngine.Static("/assets", config.StaticFiles)
+ // UI Routes: serve from --static-files on disk when it holds a built index.html (development
+ // or an explicit override); otherwise fall back to the client embedded in the binary, so a
+ // released binary is self-contained.
+ if fileExists(config.StaticFiles + "/index.html") {
+ adminServerEngine.Static("/assets", config.StaticFiles)
+ } else if embedded, ok := frontend.FS(); ok {
+ adminServerEngine.StaticFS("/assets", embedded)
+ }
adminServerEngine.GET("/*", renderIndex(adminServerEngine, config))
slog.Info("Starting admin server", "port", config.ConfigListenPort)
@@ -153,15 +160,32 @@ func serve(tlsEnable bool, servers ...*http.Server) error {
return g.Wait()
}
+// fileExists reports whether path exists (used to prefer an on-disk UI over the embedded one).
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
func renderIndex(e *echo.Echo, cfg config.Config) echo.HandlerFunc {
return func(c echo.Context) error {
- // In development mode, index.html might not be available yet
+ // Parse index.html once: from --static-files on disk if present (dev/override), otherwise
+ // from the client embedded in the binary. In development the client may not be built yet.
if templateRenderer == nil {
- template, err := template.ParseFiles(cfg.StaticFiles + "/index.html")
+ var (
+ tmpl *template.Template
+ err error
+ )
+ if diskIndex := cfg.StaticFiles + "/index.html"; fileExists(diskIndex) {
+ tmpl, err = template.ParseFiles(diskIndex)
+ } else if embedded, ok := frontend.FS(); ok {
+ tmpl, err = template.ParseFS(embedded, "index.html")
+ } else {
+ return c.String(http.StatusNotFound, "index is building...")
+ }
if err != nil {
return c.String(http.StatusNotFound, "index is building...")
}
- templateRenderer := &TemplateRenderer{template}
+ templateRenderer = &TemplateRenderer{tmpl}
e.Renderer = templateRenderer
}
diff --git a/server/frontend/dist/.gitkeep b/server/frontend/dist/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/server/frontend/frontend.go b/server/frontend/frontend.go
new file mode 100644
index 0000000..5e56c5c
--- /dev/null
+++ b/server/frontend/frontend.go
@@ -0,0 +1,27 @@
+// Package frontend embeds the built web UI so a released binary is self-contained (no need to
+// ship the client directory alongside it). The dist directory is populated by the client build
+// (see the Makefile release target); a committed placeholder keeps `go build` working before the
+// client has ever been built.
+package frontend
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed all:dist
+var dist embed.FS
+
+// FS returns the embedded client filesystem (rooted at dist) together with whether it actually
+// holds a built UI (an index.html). When only the placeholder is present it returns ok=false, so
+// callers fall back to serving from the --static-files directory on disk.
+func FS() (fs.FS, bool) {
+ sub, err := fs.Sub(dist, "dist")
+ if err != nil {
+ return nil, false
+ }
+ if _, err := fs.Stat(sub, "index.html"); err != nil {
+ return nil, false
+ }
+ return sub, true
+}
diff --git a/server/handlers/mocks.go b/server/handlers/mocks.go
index e7a4c55..63fa48c 100644
--- a/server/handlers/mocks.go
+++ b/server/handlers/mocks.go
@@ -121,11 +121,12 @@ func (m *Mocks) GenericHandler(c echo.Context) error {
/* Response writing */
- // Headers
+ // Headers: assign to the header map directly instead of Add()/Set(), which canonicalize the
+ // key (e.g. "BrokerProperties" -> "Brokerproperties"). Smocker preserves the exact casing the
+ // mock declares, matching servers that treat header names case-sensitively.
+ header := c.Response().Header()
for key, values := range response.Headers {
- for _, value := range values {
- c.Response().Header().Add(key, value)
- }
+ header[key] = []string(values)
}
// Delay
diff --git a/vite.config.ts b/vite.config.ts
index 7d0f042..008be28 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -17,6 +17,7 @@ function assetsPrefix(): Plugin {
let indexPath = "";
return {
name: "smocker-assets-prefix",
+ apply: "build",
configResolved(cfg) {
indexPath = resolve(cfg.root, cfg.build.outDir, "index.html");
},
@@ -30,13 +31,43 @@ function assetsPrefix(): Plugin {
};
}
-export default defineConfig({
+// In the dev server, Vite serves index.html without the Go template step, so the runtime globals
+// stay as literal "{{.basePath}}" / "{{.version}}" and break the API base + . Substitute
+// them for dev only; the production build keeps the placeholders for the Go server to render.
+function devIndexHtml(): Plugin {
+ return {
+ name: "smocker-dev-index-html",
+ apply: "serve",
+ transformIndexHtml(html) {
+ return html
+ .replace(/\{\{\.basePath\}\}/g, "/")
+ .replace(/\{\{\.version\}\}/g, "dev");
+ },
+ };
+}
+
+// Where `npm run dev` proxies the API to (the running `make start` admin server).
+const devProxyTarget = process.env.SMOCKER_DEV_PROXY ?? "http://localhost:8081";
+const apiProxy = Object.fromEntries(
+ ["/mocks", "/sessions", "/history", "/version", "/reset"].map((path) => [
+ path,
+ devProxyTarget,
+ ]),
+);
+
+export default defineConfig(({ command }) => ({
root: "client",
- base: "./",
- plugins: [react(), assetsPrefix()],
- // Allow importing the canonical mock schema (docs/mock.schema.json) from client code, which
- // lives above the Vite root.
- server: { fs: { allow: [import.meta.dirname] } },
+ // Dev serves from "/" (clean absolute URLs against the dev server); the build keeps "./" so the
+ // Go server's can relocate assets under any deployment base path.
+ base: command === "build" ? "./" : "/",
+ plugins: [react(), assetsPrefix(), devIndexHtml()],
+ server: {
+ // Allow importing the canonical mock schema (docs/mock.schema.json) from client code, which
+ // lives above the Vite root.
+ fs: { allow: [import.meta.dirname] },
+ // Proxy the admin API to the backend so `npm run dev` works against `make start`.
+ proxy: apiProxy,
+ },
build: {
outDir: "../build/client",
emptyOutDir: true,
@@ -48,4 +79,4 @@ export default defineConfig({
// Relative to the Vite root ("client"), so this matches client/**/*.test.{ts,tsx}.
include: ["**/*.test.{ts,tsx}"],
},
-});
+}));