From 15827e92594837935fd85c0520df8a3a6813315b Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:26:16 +0100 Subject: [PATCH] Add --port option to run local-prod on a specific port; update websocket and auth port to 3000 for consistency --- .../plans/2026-07-03-local-prod-mode.md | 12 ++++---- config/local-nginx.conf | 6 ++-- docker-compose.local-prod.yml | 4 +-- hub-client/README.md | 11 +++++-- hub-client/package.json | 2 +- scripts/README.md | 18 ++++++----- scripts/build-local-prod.sh | 13 ++++++++ scripts/local-prod-nginx.sh | 6 ++-- scripts/local-prod-port.mjs | 27 +++++++++++++++++ scripts/local-prod-port.test.mjs | 30 +++++++++++++++++++ scripts/local-prod-server.mjs | 2 +- scripts/local-prod.sh | 4 +-- 12 files changed, 107 insertions(+), 28 deletions(-) create mode 100755 scripts/build-local-prod.sh create mode 100644 scripts/local-prod-port.mjs create mode 100644 scripts/local-prod-port.test.mjs diff --git a/claude-notes/plans/2026-07-03-local-prod-mode.md b/claude-notes/plans/2026-07-03-local-prod-mode.md index 3c331f348..1ab6a387f 100644 --- a/claude-notes/plans/2026-07-03-local-prod-mode.md +++ b/claude-notes/plans/2026-07-03-local-prod-mode.md @@ -48,7 +48,7 @@ Add `npm run local-prod` command that mirrors production deployment architecture - Implements same cache headers as production (`/assets/` immutable, others no-cache) - Handles WebSocket upgrades for `/ws` route - Hub runs with `--allow-insecure-auth` for local development -- Port 3001 for hub, 8080 for static server +- Port 3000 for hub, 8080 for static server - Logs written to `.local-prod-data/{hub,static}.log` - Added `npm run build:local-prod` script that sets `VITE_DEFAULT_SYNC_SERVER=ws://127.0.0.1:8080/ws` at build time - This is required because Vite bakes env vars into the bundle @@ -59,7 +59,7 @@ Add `npm run local-prod` command that mirrors production deployment architecture cargo build --bin hub ./target/debug/hub \ --data-dir ./.local-prod-data \ - -P 3001 \ + -P 3000 \ -H 127.0.0.1 ``` @@ -76,7 +76,7 @@ cargo build --bin hub - [x] Service: hub-health-check (waits for hub before nginx starts) - [x] Create `config/local-nginx.conf` (adapted from production) - HTTP only (no TLS for local) - - Routes `/ws` → `host.docker.internal:3001` with WebSocket upgrade + - Routes `/ws` → `host.docker.internal:3000` with WebSocket upgrade - Routes `/auth`, `/api`, `/health` → hub - Routes `/assets/` with immutable cache headers - Routes `/` to static files with no-cache @@ -88,7 +88,7 @@ cargo build --bin hub **Implementation notes:** - Hub runs on HOST (not Docker) to avoid Rust/WASM build complexity in containers -- Hub binds to `0.0.0.0:3001` (not `127.0.0.1`) so Docker can reach it via `host.docker.internal` +- Hub binds to `0.0.0.0:3000` (not `127.0.0.1`) so Docker can reach it via `host.docker.internal` - Health check container waits for hub before nginx starts (prevents startup race) - nginx uses `alias` for `/assets/` to avoid path prefix issues - Script follows nginx logs with `docker compose logs -f nginx` @@ -100,7 +100,7 @@ server { server_name localhost; location /ws { - proxy_pass http://host.docker.internal:3001; + proxy_pass http://host.docker.internal:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; @@ -147,7 +147,7 @@ server { ## Open Questions 1. **Data persistence**: Should `.local-prod-data/` be gitignored? (Yes, probably) -2. **Port conflicts**: What if :3001 or :8080 are taken? (Document or auto-detect) +2. **Port conflicts**: What if :3000 or :8080 are taken? (Document or auto-detect) 3. **OAuth/auth**: Production uses OIDC. Do we need a mock? (Phase 1: skip auth) 4. **TLS in dev**: Worth the cert complexity? (No, HTTP is fine for local) 5. **Service worker**: Disable in local-prod like E2E tests? (Yes, cache interference) diff --git a/config/local-nginx.conf b/config/local-nginx.conf index a02f64104..c231cf8a7 100644 --- a/config/local-nginx.conf +++ b/config/local-nginx.conf @@ -42,7 +42,7 @@ server { # WebSocket proxy to hub location /ws { - proxy_pass http://127.0.0.1:3001; + proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; @@ -66,7 +66,7 @@ server { # Quarto project artifacts (CSS, JS generated during preview/render) # Use ^~ to stop searching after this match (priority over regex and / location) location ^~ /.quarto/ { - proxy_pass http://127.0.0.1:3001; + proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -75,7 +75,7 @@ server { # API/auth proxy to hub location ~ ^/(auth|api|health) { - proxy_pass http://127.0.0.1:3001; + proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/docker-compose.local-prod.yml b/docker-compose.local-prod.yml index 31f8abcbf..c909eb367 100644 --- a/docker-compose.local-prod.yml +++ b/docker-compose.local-prod.yml @@ -26,7 +26,7 @@ services: sh -c " echo 'Waiting for hub to be ready...'; for i in 1 2 3 4 5 6 7 8 9 10; do - if curl -f http://host.docker.internal:3001/health 2>/dev/null; then + if curl -f http://host.docker.internal:3000/health 2>/dev/null; then echo 'Hub is ready!'; exit 0; fi; @@ -49,7 +49,7 @@ networks: # test the reverse proxy layer. # # Start hub separately: -# cargo run --bin hub -- --data-dir ./.local-prod-data -P 3001 -H 0.0.0.0 --allow-insecure-auth +# cargo run --bin hub -- --data-dir ./.local-prod-data -P 3000 -H 0.0.0.0 --allow-insecure-auth # # Then start nginx: # docker compose -f docker-compose.local-prod.yml up diff --git a/hub-client/README.md b/hub-client/README.md index 4514d3140..8adbfbe99 100644 --- a/hub-client/README.md +++ b/hub-client/README.md @@ -106,10 +106,15 @@ This produces a complete production build in `dist/`. Test hub-client in a production-like setup locally: ```bash -# Build + run -npm run local-prod:fresh +# Build + run on the default port +npm run build:local-prod +npm run local-prod -# Open http://127.0.0.1:8080 +# Build + run on a different port +npm run build:local-prod -- --port 9000 +npm run local-prod -- --port 9000 + +# Open http://127.0.0.1:8080 (or the selected port) ``` Two modes available: diff --git a/hub-client/package.json b/hub-client/package.json index e68dbc470..5b57d58a2 100644 --- a/hub-client/package.json +++ b/hub-client/package.json @@ -11,7 +11,7 @@ "build:wasm": "node scripts/build-wasm.js", "build:sandboxed": "cd quarto-hub-sandboxed-preview && npm install --silent && npm run build", "build:all": "npm run build:wasm && npm run build:sandboxed && npm run build", - "build:local-prod": "npm run build:wasm && npm run build:sandboxed && VITE_DEFAULT_SYNC_SERVER=ws://127.0.0.1:8080/ws VITE_Q2_SANDBOXED_PREVIEW_URL=http://127.0.0.1:8081/q2-sandboxed-preview.html NODE_OPTIONS=--max-old-space-size=4096 vite build", + "build:local-prod": "../scripts/build-local-prod.sh", "postinstall": "cd quarto-hub-sandboxed-preview && npm install --silent", "typecheck": "tsc -p tsconfig.app.json --noEmit", "lint": "eslint .", diff --git a/scripts/README.md b/scripts/README.md index 947ebdd3b..e9e955e7e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,7 +12,7 @@ Two modes available: ### What it does Mirrors the production deployment architecture: -- Starts the `hub` binary (Rust server) on port 3001 +- Starts the `hub` binary (Rust server) on port 3000 - Serves the built hub-client on port 8080 - Serves q2-sandboxed-preview.html on port 8081 (sandboxed, simulates separate domain) - Proxies `/auth` and `/ws` requests to the hub server @@ -29,11 +29,15 @@ cd hub-client && npm run build:local-prod && cd .. cd hub-client npm run local-prod +# Use a different port (pass it to both build and run) +npm run build:local-prod -- --port 9000 +npm run local-prod -- --port 9000 + # Or run directly ./scripts/local-prod.sh ``` -**Important:** Use `npm run build:local-prod` (NOT `build:all`) to build with the correct sync server URL (`ws://127.0.0.1:8080/ws`) baked in. +**Important:** Use `npm run build:local-prod` (NOT `build:all`) to build with the local sync server URL baked in. If using a non-default port, pass `--port PORT` to both `build:local-prod` and `local-prod`. Open `http://127.0.0.1:8080` in your browser. Press **Ctrl-C** to shut down gracefully. @@ -41,15 +45,15 @@ Open `http://127.0.0.1:8080` in your browser. Press **Ctrl-C** to shut down grac ``` Browser → http://127.0.0.1:8080 (Node.js proxy - main app) - ├─ /auth → proxy to hub:3001 - ├─ /ws → WebSocket upgrade to hub:3001 + ├─ /auth → proxy to hub:3000 + ├─ /ws → WebSocket upgrade to hub:3000 ├─ /assets/* → serve from dist/ (immutable cache) └─ /* → serve from dist/ (no-cache, SPA fallback) Browser → http://127.0.0.1:8081 (q2-sandboxed-preview server - sandboxed) └─ /q2-sandboxed-preview.html → sandboxed AST renderer (separate origin) -hub:3001 (Rust binary) → .local-prod-data/ +hub:3000 (Rust binary) → .local-prod-data/ ``` **Why q2-sandboxed-preview.html on a separate port?** @@ -82,8 +86,8 @@ Tests the actual nginx configuration from production. Use when: - Debugging nginx-specific issues **Architecture differences:** -- **Node.js mode:** Browser → Node.js proxy (port 8080) → hub (port 3001) -- **Nginx mode:** Browser → nginx (Docker, port 8080) → hub (host, port 3001) +- **Node.js mode:** Browser → Node.js proxy (port 8080) → hub (port 3000) +- **Nginx mode:** Browser → nginx (Docker, port 8080) → hub (host, port 3000) - **Production:** Browser → nginx (native) → hub (native, port 3000) **Differences from production:** HTTP (no TLS), no OIDC auth, single-machine. diff --git a/scripts/build-local-prod.sh b/scripts/build-local-prod.sh new file mode 100755 index 000000000..9ab794683 --- /dev/null +++ b/scripts/build-local-prod.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HUB_CLIENT_DIR="$(cd "$SCRIPT_DIR/../hub-client" && pwd)" +STATIC_PORT="$(node "$SCRIPT_DIR/local-prod-port.mjs" "$@")" + +cd "$HUB_CLIENT_DIR" +npm run build:wasm +npm run build:sandboxed +VITE_DEFAULT_SYNC_SERVER="ws://127.0.0.1:$STATIC_PORT/ws" \ +VITE_Q2_SANDBOXED_PREVIEW_URL=http://127.0.0.1:8081/q2-sandboxed-preview.html \ +NODE_OPTIONS=--max-old-space-size=4096 vite build diff --git a/scripts/local-prod-nginx.sh b/scripts/local-prod-nginx.sh index 4ba3da6f6..6919512a1 100755 --- a/scripts/local-prod-nginx.sh +++ b/scripts/local-prod-nginx.sh @@ -8,7 +8,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" HUB_CLIENT_DIR="$PROJECT_ROOT/hub-client" DATA_DIR="$PROJECT_ROOT/.local-prod-data" -HUB_PORT=3001 +HUB_PORT=3000 NGINX_PORT=8080 Q2_SANDBOXED_PREVIEW_PORT=8081 @@ -226,8 +226,8 @@ log_info "q2-sandboxed-preview: ${GREEN}http://127.0.0.1:$Q2_SANDBOXED_PREVIE log_info "" log_info "Architecture:" log_info " Browser → nginx:8080 (native)" -log_info " ├─ /ws → hub:3001 (WebSocket)" -log_info " ├─ /auth → hub:3001" +log_info " ├─ /ws → hub:3000 (WebSocket)" +log_info " ├─ /auth → hub:3000" log_info " └─ /* → static files" log_info " Browser → nginx:8081 → q2-sandboxed-preview:8081 (sandboxed)" log_info "" diff --git a/scripts/local-prod-port.mjs b/scripts/local-prod-port.mjs new file mode 100644 index 000000000..7d7450627 --- /dev/null +++ b/scripts/local-prod-port.mjs @@ -0,0 +1,27 @@ +export function parseLocalProdPort(args, defaultPort = 8080) { + const portIndex = args.indexOf('--port'); + if (portIndex === -1) { + return defaultPort; + } + + const value = args[portIndex + 1]; + if (value === undefined || !/^\d+$/.test(value)) { + throw new Error('Expected --port to be followed by a port number'); + } + + const port = Number(value); + if (port < 1 || port > 65535) { + throw new Error('Port must be between 1 and 65535'); + } + + return port; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { + console.log(parseLocalProdPort(process.argv.slice(2))); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} diff --git a/scripts/local-prod-port.test.mjs b/scripts/local-prod-port.test.mjs new file mode 100644 index 000000000..d05a2abbf --- /dev/null +++ b/scripts/local-prod-port.test.mjs @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { parseLocalProdPort } from './local-prod-port.mjs'; + +describe('parseLocalProdPort', () => { + it('uses the default port when no flag is provided', () => { + expect(parseLocalProdPort([])).toBe(8080); + }); + + it('parses an explicit port', () => { + expect(parseLocalProdPort(['--port', '9000'])).toBe(9000); + }); + + it('rejects a missing or invalid port', () => { + expect(() => parseLocalProdPort(['--port'])).toThrow(); + expect(() => parseLocalProdPort(['--port', '70000'])).toThrow(); + }); + + it('keeps the hub proxy on port 3000', async () => { + const serverScript = await readFile(new URL('./local-prod-server.mjs', import.meta.url), 'utf8'); + const launcherScript = await readFile(new URL('./local-prod.sh', import.meta.url), 'utf8'); + const nginxConfig = await readFile(new URL('../config/local-nginx.conf', import.meta.url), 'utf8'); + const composeConfig = await readFile(new URL('../docker-compose.local-prod.yml', import.meta.url), 'utf8'); + + expect(serverScript).toContain("process.env.HUB_PORT || '3000'"); + expect(launcherScript).toContain('HUB_PORT=3000'); + expect(nginxConfig).not.toContain(':3001'); + expect(composeConfig).not.toContain(':3001'); + }); +}); diff --git a/scripts/local-prod-server.mjs b/scripts/local-prod-server.mjs index 42aa32557..30d934811 100755 --- a/scripts/local-prod-server.mjs +++ b/scripts/local-prod-server.mjs @@ -14,7 +14,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const STATIC_PORT = parseInt(process.env.STATIC_PORT || '8080'); -const HUB_PORT = parseInt(process.env.HUB_PORT || '3001'); +const HUB_PORT = parseInt(process.env.HUB_PORT || '3000'); const DIST_DIR = path.join(__dirname, '../hub-client/dist'); // MIME types for common extensions diff --git a/scripts/local-prod.sh b/scripts/local-prod.sh index ff26416c9..4f6a029ef 100755 --- a/scripts/local-prod.sh +++ b/scripts/local-prod.sh @@ -8,8 +8,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" HUB_CLIENT_DIR="$PROJECT_ROOT/hub-client" DATA_DIR="$PROJECT_ROOT/.local-prod-data" -HUB_PORT=3001 -STATIC_PORT=8080 +STATIC_PORT="$(node "$SCRIPT_DIR/local-prod-port.mjs" "$@")" +HUB_PORT=3000 Q2_SANDBOXED_PREVIEW_PORT=8081 # Color output