diff --git a/docker-compose.split-web.yaml b/docker-compose.split-web.yaml new file mode 100644 index 000000000..6e7cefa77 --- /dev/null +++ b/docker-compose.split-web.yaml @@ -0,0 +1,52 @@ +services: + fabro-api: + image: ${FABRO_IMAGE:-fabro-sh/fabro:split-web-poc} + restart: unless-stopped + expose: + - "32276" + command: + - sh + - -c + - >- + exec fabro server start --foreground + --bind 0.0.0.0:32276 + --config /config/settings.toml + volumes: + - fabro-storage:/storage + - /var/run/docker.sock:/var/run/docker.sock + - ./docker/split-web/settings.toml:/config/settings.toml:ro + environment: + FABRO_WEB_URL: http://localhost:${SPLIT_WEB_PORT:-8080} + SESSION_SECRET: ${SESSION_SECRET:-} + FABRO_DEV_TOKEN: ${FABRO_DEV_TOKEN:-} + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:32276/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + fabro-web: + image: caddy:2-alpine + restart: unless-stopped + expose: + - "80" + volumes: + - ./apps/fabro-web:/srv/web:ro + - ./docker/split-web/Caddyfile.static:/etc/caddy/Caddyfile:ro + + edge: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "${SPLIT_WEB_PORT:-8080}:80" + volumes: + - ./docker/split-web/Caddyfile.edge:/etc/caddy/Caddyfile:ro + depends_on: + fabro-api: + condition: service_healthy + fabro-web: + condition: service_started + +volumes: + fabro-storage: diff --git a/docker/split-web/Caddyfile.edge b/docker/split-web/Caddyfile.edge new file mode 100644 index 000000000..efeee940e --- /dev/null +++ b/docker/split-web/Caddyfile.edge @@ -0,0 +1,14 @@ +:80 { + encode gzip zstd + + @api path /api/* /auth/* /health + handle @api { + header X-Fabro-PoC-Upstream fabro-api + reverse_proxy fabro-api:32276 + } + + handle { + header X-Fabro-PoC-Upstream fabro-web + reverse_proxy fabro-web:80 + } +} diff --git a/docker/split-web/Caddyfile.static b/docker/split-web/Caddyfile.static new file mode 100644 index 000000000..2e1af9984 --- /dev/null +++ b/docker/split-web/Caddyfile.static @@ -0,0 +1,39 @@ +:80 { + root * /srv/web/dist + encode gzip zstd + + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy strict-origin-when-cross-origin + Cross-Origin-Opener-Policy same-origin + Cross-Origin-Resource-Policy same-origin + Permissions-Policy "accelerometer=(), autoplay=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), usb=(), web-share=(), xr-spatial-tracking=()" + Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://avatars.githubusercontent.com; connect-src 'self' ws: wss:; worker-src 'self' blob:; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" + } + + @sourceMaps path *.map + handle @sourceMaps { + respond 404 + } + + @cacheAssets { + path /assets/* + not path *.map + } + header @cacheAssets Cache-Control "public, max-age=31536000, immutable" + + @htmlNavigation { + header Accept *text/html* + not path /assets/* + } + handle @htmlNavigation { + try_files {path} /index.html + header Cache-Control "no-cache" + file_server + } + + handle { + file_server + } +} diff --git a/docker/split-web/README.md b/docker/split-web/README.md new file mode 100644 index 000000000..c71747067 --- /dev/null +++ b/docker/split-web/README.md @@ -0,0 +1,67 @@ +# Split Web Compose PoC + +This Compose stack proves that Fabro can serve the React SPA from a separate +static process while the Rust server remains the API and browser-auth origin. + +Request ownership: + +- `/api/*` -> `fabro-api:32276` +- `/auth/*` -> `fabro-api:32276` +- `/health` -> `fabro-api:32276` +- everything else -> `fabro-web:80` + +The Rust server still contains bundled SPA assets. In this PoC they are simply +not reachable through the `edge` service for normal web paths. + +The edge proxy adds `X-Fabro-PoC-Upstream` to responses so manual checks can +confirm which service handled a request. + +## Run + +Build the local Fabro image from the current tree: + +```sh +cargo dev docker-build --tag fabro-sh/fabro:split-web-poc +``` + +Set local auth secrets: + +```sh +export SESSION_SECRET="$(openssl rand -hex 32)" +export FABRO_DEV_TOKEN="fabro_dev_$(openssl rand -hex 32)" +``` + +Start the split stack: + +```sh +docker compose -f docker-compose.split-web.yaml up +``` + +Open http://localhost:8080. + +Use `SPLIT_WEB_PORT` to expose a different local port, or `FABRO_IMAGE` to use +a different API image. + +## Validate + +```sh +curl -i http://localhost:8080/health +curl -i http://localhost:8080/api/v1/health +curl -I http://localhost:8080/runs +curl -I http://localhost:8080/assets/app.css + +curl -c /tmp/fabro.cookies \ + -H "content-type: application/json" \ + -d "{\"token\":\"$FABRO_DEV_TOKEN\"}" \ + http://localhost:8080/auth/login/dev-token + +curl -b /tmp/fabro.cookies http://localhost:8080/api/v1/auth/me +``` + +Expected results: + +- `/runs` and `/assets/*` are served by the static `fabro-web` container. +- `/api/*`, `/auth/*`, and `/health` are served by the Rust `fabro-api` + container through the same browser origin. +- Dev-token login sets a same-origin session cookie, and + `/api/v1/auth/me` accepts it. diff --git a/docker/split-web/settings.toml b/docker/split-web/settings.toml new file mode 100644 index 000000000..f9159278e --- /dev/null +++ b/docker/split-web/settings.toml @@ -0,0 +1,8 @@ +_version = 1 + +[server.web] +enabled = true +url = "{{ env.FABRO_WEB_URL }}" + +[server.auth] +methods = ["dev-token"]