mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-13 16:04:44 +00:00
feat: let web and admin be served from a static host by teaching each app's own entrypoint to render config.js wherever WARMBLY_CONFIG_OUT points, so one definition of the runtime key set serves both the container that renders it at start and a build:pages script that renders it into dist, ship a _redirects in each so a deep link stops 404ing without nginx try_files, and add scripts/check-pages-build.sh to make lint because a malformed config.js reads fine in a diff and leaves the app blank at runtime
This commit is contained in:
@@ -37,7 +37,7 @@ PROTOC_GEN_GO_GRPC_VERSION ?= v1.6.1
|
||||
PROTO_DIR := internal/tasks/proto
|
||||
PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go
|
||||
|
||||
.PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt lint check-migrations join-check split-cloud-check proto check-proto \
|
||||
.PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt lint check-migrations join-check split-cloud-check pages-check proto check-proto \
|
||||
up upgrade claim doctor cli seed-demo seed seed-plan sandbox sandbox-seed sandbox-simulate reset logs status stop down test-seed \
|
||||
restart restart-go restart-all infra infra-down app app-down app-logs \
|
||||
backend forms forms-web consumer worker run dev tracking realtime web \
|
||||
@@ -82,7 +82,7 @@ cli-check:
|
||||
fmt:
|
||||
gofmt -w ./cmd ./internal
|
||||
|
||||
lint: check-migrations join-check split-cloud-check check-dockerfiles
|
||||
lint: check-migrations join-check split-cloud-check pages-check check-dockerfiles
|
||||
./scripts/check-forms-mirror.sh
|
||||
$(GO_BIN)/golangci-lint run --timeout=5m
|
||||
|
||||
@@ -99,6 +99,13 @@ check-migrations:
|
||||
split-cloud-check:
|
||||
@./scripts/check-split-cloud.sh
|
||||
|
||||
# web and admin on a static host. They read their configuration from a
|
||||
# config.js the container entrypoint renders at start, and a static host has no
|
||||
# container start, so the same script renders it at build time. This runs both
|
||||
# entrypoints and checks the result is what the app expects.
|
||||
pages-check:
|
||||
@./scripts/check-pages-build.sh
|
||||
|
||||
# A COPY naming a path no longer in the repo builds green everywhere until it
|
||||
# lands: nothing in `make lint` or the CI workflow builds an image, and
|
||||
# build-push.yml runs only on push to main. Runs in a second; part of `make
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Render the runtime config from container env so a single built image serves
|
||||
# any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook).
|
||||
#
|
||||
# WARMBLY_CONFIG_OUT moves where it writes, which is how a static host that
|
||||
# has no container start renders the same file at build time. One definition
|
||||
# of the key set, so the two paths cannot drift apart.
|
||||
set -eu
|
||||
|
||||
CONFIG_OUT="${WARMBLY_CONFIG_OUT:-/usr/share/nginx/html/config.js}"
|
||||
mkdir -p "$(dirname "$CONFIG_OUT")"
|
||||
|
||||
# Values are written into JavaScript string literals, so a double quote, a
|
||||
# backslash or a line break in one would end the literal early and take the
|
||||
# whole config with it, leaving the app with no API_URL at all. Escape rather
|
||||
@@ -11,7 +18,7 @@ js() {
|
||||
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\r\n'
|
||||
}
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
cat > "$CONFIG_OUT" <<EOF
|
||||
window.__WARMBLY_ENV__ = {
|
||||
API_URL: "$(js "${WARMBLY_API_URL:-}")",
|
||||
DASHBOARD_URL: "$(js "${WARMBLY_DASHBOARD_URL:-}")",
|
||||
@@ -24,4 +31,4 @@ EOF
|
||||
|
||||
# The redirect truncates in place and keeps whatever mode the built file had, so
|
||||
# a restrictive umask or checkout leaves nginx serving 403 for the whole config.
|
||||
chmod 644 /usr/share/nginx/html/config.js
|
||||
chmod 644 "$CONFIG_OUT"
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
"test:run": "vitest run",
|
||||
"build:pages": "vite build && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Client-side routing on a static host. Without this, every route below the
|
||||
# root 404s on a refresh or a shared link: the file genuinely does not exist,
|
||||
# and only the app knows the route. nginx does the same thing with try_files.
|
||||
#
|
||||
# 200, not 301: the URL has to stay what the user asked for, or the router
|
||||
# lands on the wrong route.
|
||||
/* /index.html 200
|
||||
@@ -174,6 +174,7 @@ Full behavior, including what each registration mode does to the sign-up form, i
|
||||
| `CAPTCHA_PROVIDER` | `none` or `turnstile` | derived, see below | yes |
|
||||
| `TURNSTILE_SECRET` | Cloudflare Turnstile secret, read by the backend | unset | yes |
|
||||
| `WARMBLY_TURNSTILE_KEY` | The Turnstile site key, read by the dashboard and admin panel at container start | a test key under compose | yes (container start) |
|
||||
| `WARMBLY_CONFIG_OUT` | Where the dashboard and admin panel write their runtime `config.js`. Defaults to the path nginx serves; a static host sets it to the build output so the same script renders the file at build time instead of container start | the nginx path | yes (build or container start) |
|
||||
| `TURNSTILE_BYPASS_TOKEN` | A token that skips verification. Only honoured when `APP_ENV=dev` | unset | yes |
|
||||
| `TURNSTILE_SITE_KEY` | The public Turnstile widget key. The backend hands it to the forms service, which renders it on hosted form pages that enable spam protection. The frontends carry their own copy in `WARMBLY_TURNSTILE_KEY` | unset | no (read per request) |
|
||||
|
||||
|
||||
@@ -163,6 +163,36 @@ Nothing connects back to the machine, then or later. It needs no inbound port an
|
||||
|
||||
</Steps>
|
||||
|
||||
## The dashboard and admin panel on a static host
|
||||
|
||||
`web` and `admin` are static builds, so they do not need a container. Serving them from a CDN is cheaper and faster than running two nginx containers on your container host.
|
||||
|
||||
The one thing to know: both read `window.__WARMBLY_ENV__` from `/config.js`, which the container entrypoint renders from environment at start. A static host has no container start, so the same script renders it at build time instead.
|
||||
|
||||
```
|
||||
Build command: pnpm build:pages
|
||||
Build output: dist
|
||||
Root directory: web (or admin)
|
||||
```
|
||||
|
||||
`build:pages` runs the normal build and then the app's own entrypoint with `WARMBLY_CONFIG_OUT=dist/config.js`. One definition of the key set serves both paths, so the container and the static host cannot drift apart.
|
||||
|
||||
Set these as build environment variables on the host:
|
||||
|
||||
| `web` | `admin` |
|
||||
|---|---|
|
||||
| `WARMBLY_API_URL` | `WARMBLY_API_URL` |
|
||||
| `WARMBLY_APP_URL` | `WARMBLY_DASHBOARD_URL` |
|
||||
| `WARMBLY_TURNSTILE_KEY` | `WARMBLY_ENV_LABEL` |
|
||||
| `WARMBLY_SENTRY_DSN` | `WARMBLY_TURNSTILE_KEY` |
|
||||
| `WARMBLY_POSTHOG_KEY` | `WARMBLY_SENTRY_DSN` |
|
||||
|
||||
<Callout title="Both apps ship a _redirects">
|
||||
`/* /index.html 200`, copied into the build root. Without it every route below the root returns 404 on a refresh or a shared link, because the file genuinely does not exist and only the app knows the route. It is the static-host equivalent of nginx `try_files`.
|
||||
</Callout>
|
||||
|
||||
Whichever origin you serve them from has to be in the backend's `CORS_ALLOW_ORIGINS`, or the dashboard loads and every API call fails preflight.
|
||||
|
||||
## Anything the control plane cannot know
|
||||
|
||||
`warmbly join` writes `/etc/warmbly/node.env` from the control plane's answer and rewrites it on every join. Next to it, `/etc/warmbly/node.local.env` is created once and never written again, and the container reads it second, so a name repeated there wins.
|
||||
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/sh
|
||||
# Everything CI should know about serving web and admin from a static host.
|
||||
#
|
||||
# Those apps read window.__WARMBLY_ENV__ from /config.js, which the container
|
||||
# entrypoint renders at start. A static host has no container start, so the
|
||||
# same script renders it at build time via WARMBLY_CONFIG_OUT. Both paths share
|
||||
# one definition of the key set precisely so they cannot drift, and this checks
|
||||
# the sharing still works.
|
||||
#
|
||||
# It runs the real entrypoints rather than reading them: a config.js that is
|
||||
# subtly malformed still looks fine in a diff and leaves the app with no API
|
||||
# URL at runtime, which presents as a blank page and nothing in the logs.
|
||||
set -eu
|
||||
|
||||
fail() { printf 'check-pages-build: %s\n' "$*" >&2; exit 1; }
|
||||
ok() { printf ' ok %s\n' "$*"; }
|
||||
skip() { printf ' -- %s\n' "$*"; }
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
for app in web admin; do
|
||||
entry="$app/docker-entrypoint.sh"
|
||||
[ -f "$entry" ] || fail "$entry not found (run from the repository root)"
|
||||
|
||||
if command -v dash >/dev/null 2>&1; then
|
||||
dash -n "$entry" || fail "dash -n failed on $entry"
|
||||
else
|
||||
sh -n "$entry" || fail "sh -n failed on $entry"
|
||||
fi
|
||||
|
||||
# The container path must stay exactly what it was: this file is the nginx
|
||||
# entrypoint hook, and a changed default silently stops the image working.
|
||||
grep -q '/usr/share/nginx/html/config.js' "$entry" \
|
||||
|| fail "$entry no longer defaults to the nginx path; the container image would serve no config"
|
||||
|
||||
out="$WORK/$app/config.js"
|
||||
WARMBLY_CONFIG_OUT="$out" \
|
||||
WARMBLY_API_URL="https://api.example.com" \
|
||||
WARMBLY_APP_URL="https://app.example.com" \
|
||||
WARMBLY_DASHBOARD_URL="https://app.example.com" \
|
||||
sh "$entry" || fail "$entry failed to render to WARMBLY_CONFIG_OUT"
|
||||
|
||||
[ -f "$out" ] || fail "$entry ignored WARMBLY_CONFIG_OUT; a static build would ship no config.js"
|
||||
|
||||
grep -q 'window.__WARMBLY_ENV__' "$out" \
|
||||
|| fail "$app config.js does not define window.__WARMBLY_ENV__"
|
||||
grep -q 'API_URL: "https://api.example.com"' "$out" \
|
||||
|| fail "$app config.js did not pick up WARMBLY_API_URL"
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node --check "$out" >/dev/null 2>&1 \
|
||||
|| fail "$app config.js is not valid JavaScript; the app would load with no configuration at all"
|
||||
fi
|
||||
|
||||
# Vite copies public/ to the build root, so this is what lands beside
|
||||
# index.html. Without it every deep link 404s on a static host.
|
||||
[ -f "$app/public/_redirects" ] || fail "$app/public/_redirects is missing; deep links would 404 on a static host"
|
||||
grep -qE '^/\*[[:space:]]+/index\.html[[:space:]]+200' "$app/public/_redirects" \
|
||||
|| fail "$app/public/_redirects has no SPA rule serving index.html with 200"
|
||||
|
||||
# The build a static host runs has to exist and has to render the config.
|
||||
grep -q '"build:pages"' "$app/package.json" \
|
||||
|| fail "$app/package.json has no build:pages script"
|
||||
grep -q 'WARMBLY_CONFIG_OUT=dist/config.js' "$app/package.json" \
|
||||
|| fail "$app build:pages does not render config.js into the build output"
|
||||
|
||||
ok "$app renders config.js, has the SPA rule, and keeps the container default"
|
||||
done
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
skip "node not installed; skipped the JavaScript syntax check"
|
||||
fi
|
||||
|
||||
printf 'check-pages-build: all checks passed\n'
|
||||
@@ -1,8 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Render the runtime config from container env so a single built image serves
|
||||
# any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook).
|
||||
#
|
||||
# WARMBLY_CONFIG_OUT moves where it writes, which is how a static host that
|
||||
# has no container start renders the same file at build time. One definition
|
||||
# of the key set, so the two paths cannot drift apart.
|
||||
set -eu
|
||||
|
||||
CONFIG_OUT="${WARMBLY_CONFIG_OUT:-/usr/share/nginx/html/config.js}"
|
||||
mkdir -p "$(dirname "$CONFIG_OUT")"
|
||||
|
||||
# Values are written into JavaScript string literals, so a double quote, a
|
||||
# backslash or a line break in one would end the literal early and take the
|
||||
# whole config with it, leaving the app with no API_URL at all. Escape rather
|
||||
@@ -11,7 +18,7 @@ js() {
|
||||
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\r\n'
|
||||
}
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
cat > "$CONFIG_OUT" <<EOF
|
||||
window.__WARMBLY_ENV__ = {
|
||||
API_URL: "$(js "${WARMBLY_API_URL:-}")",
|
||||
APP_URL: "$(js "${WARMBLY_APP_URL:-}")",
|
||||
@@ -25,4 +32,4 @@ EOF
|
||||
|
||||
# The redirect truncates in place and keeps whatever mode the built file had, so
|
||||
# a restrictive umask or checkout leaves nginx serving 403 for the whole config.
|
||||
chmod 644 /usr/share/nginx/html/config.js
|
||||
chmod 644 "$CONFIG_OUT"
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"build:pages": "vite build && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Client-side routing on a static host. Without this, every route below the
|
||||
# root 404s on a refresh or a shared link: the file genuinely does not exist,
|
||||
# and only the app knows the route. nginx does the same thing with try_files.
|
||||
#
|
||||
# 200, not 301: the URL has to stay what the user asked for, or the router
|
||||
# lands on the wrong route.
|
||||
/* /index.html 200
|
||||
Reference in New Issue
Block a user