Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions claude-notes/plans/2026-07-03-local-prod-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand All @@ -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
Expand All @@ -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`
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions config/local-nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.local-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
11 changes: 8 additions & 3 deletions hub-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion hub-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
18 changes: 11 additions & 7 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,27 +29,31 @@ 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.

### Architecture

```
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?**
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions scripts/build-local-prod.sh
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions scripts/local-prod-nginx.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 ""
Expand Down
27 changes: 27 additions & 0 deletions scripts/local-prod-port.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
}
30 changes: 30 additions & 0 deletions scripts/local-prod-port.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion scripts/local-prod-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions scripts/local-prod.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading