diff --git a/.env.example b/.env.example index f2796f2b..7b8c1db0 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,13 @@ PUBLIC_ORIGIN= # as a fallback to the pub/sub-based instant revocation. SSE_SESSION_RECHECK_SECONDS=15 +# Set to a running Vite dev server's origin to get HMR while visiting the +# BFF's own port (:3000) instead of Vite's (:5173) — the BFF then proxies +# everything it doesn't own (SPA shell, JS/CSS modules, HMR) to Vite. Unset +# (default) = serve the built SPA from server/public/ as usual. Usually set +# per-invocation instead of here — see `npm run dev:all` / `npm run dev:bff`. +# VITE_DEV_SERVER_URL=http://localhost:5173 + LOG_LEVEL=info # Used by `npm run e2e:docker` — credentials for a real backend user the e2e diff --git a/README.md b/README.md index 0142f9ff..d85ece1e 100644 --- a/README.md +++ b/README.md @@ -88,18 +88,29 @@ Bring them up in this order: npm run dev # :3000, tsx watch, reads ../.env ``` -3. **Build the frontend for the BFF to serve**, from the repo root: + Iterating on the frontend with HMR (step 3)? Start this with + `VITE_DEV_SERVER_URL=http://localhost:5173 npm run dev` instead — see + step 3 for why. Or skip steps 2 and 3 entirely and run `npm run dev:all` + from the repo root, which starts both with that already set. + +3. **Start the frontend** (terminal C, from the repo root): ```bash npm install - npm run build + npm run dev # :5173 ``` - This builds the SPA into `server/public/`, which the already-running BFF - serves directly. Re-run `npm run build` after any frontend change; - there's no HMR dev server wired to the BFF, so this build step is the - loop for local iteration against the real backend. (`npm run build:watch` - reruns it automatically on file changes.) + Don't visit `:5173` directly — keep visiting the BFF on `:3000` (step + 4). With `VITE_DEV_SERVER_URL` set (step 2), the BFF reverse-proxies + everything it doesn't own itself (SPA shell, JS/CSS modules, HMR) to + this Vite dev server (`server/src/plugins/vite-dev-proxy.ts`), so the + browser only ever talks to one origin (`:3000`) and gets real HMR + instead of the rebuild-and-refresh `build:watch` loop. `/api/*`, + `/auth/*`, etc. stay handled by the BFF itself, unaffected by the proxy. + + Terminals B and C can be replaced with one: `npm run dev:all` runs both + via `concurrently` (already sets `VITE_DEV_SERVER_URL` for you). The API + (terminal A) still needs its own terminal since it's a separate repo. 4. **Use it.** Visit `http://localhost:3000/`: redirects to `/app/login` (unauthed) or `/app/` (authed). The login form posts through the BFF, @@ -110,9 +121,11 @@ Bring them up in this order: forces a password change unless `PASSWORD_CHANGE_ENFORCEMENT_ENABLED=false` is set in the API's `.env`). -> `npm run dev` (plain Vite dev server at `:5173`, no BFF in front) still -> works for UI-only iteration, but `/api/*` calls need the BFF — it won't -> reach the ContextForge API on its own. +> Testing the exact BFF-served bundle (no Vite dev server, no HMR)? Run +> `npm run build` (or `npm run build:watch` to rebuild on change) instead of +> step 3, and start the BFF in step 2 with plain `npm run dev` (no +> `VITE_DEV_SERVER_URL`) — you're still visiting `http://localhost:3000/` +> either way. #### Troubleshooting @@ -121,6 +134,14 @@ Bring them up in this order: - **401 mid-session**: expected; the API token hard-expires per `TOKEN_EXPIRY` (default 20 min). The BFF auto-revokes the session and redirects to login. +- **`:3000` doesn't reflect frontend changes / no HMR**: the BFF wasn't + started with `VITE_DEV_SERVER_URL=http://localhost:5173` (step 2), so + it's serving the last `server/public/` build instead of proxying to + Vite. Use `npm run dev:all`, or set the env var yourself. +- **502/connection error on `:3000` for non-API paths**: `VITE_DEV_SERVER_URL` + is set but the Vite dev server (step 3) isn't actually running yet — the + BFF proxies to it lazily per-request, so start Vite first (or use + `npm run dev:all`, which starts both). ### Build @@ -359,31 +380,32 @@ contextforge-web-ui/ ## Available Scripts -Run from the repo root unless noted. The app you actually visit is the BFF -on `:3000` — see [Getting Started](#getting-started) for the full three-process -setup. - -| Script | Description | -| ---------------------------- | ----------------------------------------------------------------------------------- | -| `npm run dev` (in `server/`) | **Start the BFF (`:3000`)** — serves the SPA and proxies `/api/*` to the API | -| `npm run dev` | Vite dev server (`:5173`), UI-only — no BFF in front, so `/api/*` calls won't work | -| `npm run build` | Build the SPA into `server/public/`, which the BFF serves (also the local dev loop) | -| `npm run build:watch` | Rebuild on change — the iteration loop when running against the real API | -| `npm run generate` | Regenerate API types from `openapi.json` | -| `npm run preview` | Preview production build | -| `npm run lint` | Check for linting errors | -| `npm run lint:fix` | Auto-fix linting errors | -| `npm run format` | Format all files with Prettier | -| `npm run format:check` | Check formatting without changes | -| `npm run test` | Run tests in watch mode | -| `npm run test:run` | Run tests once (CI mode) | -| `npm run test:ui` | Run tests with UI | -| `npm run test:coverage` | Generate coverage report | -| `npm run e2e` | Run Playwright E2E tests | -| `npm run e2e:ui` | Playwright UI mode | -| `npm run e2e:debug` | Playwright Inspector | -| `npm run e2e:install` | Install Playwright browsers | -| `npm run e2e:report` | Open last Playwright report | +Run from the repo root unless noted. Visit the app on the BFF's port +(`:3000`) either way — see [Getting Started](#getting-started) for the full +dev setup. + +| Script | Description | +| ---------------------------- | ------------------------------------------------------------------------------------------------- | +| `npm run dev` | Vite dev server (`:5173`) — not visited directly; the BFF proxies to it for HMR | +| `npm run dev` (in `server/`) | **Start the BFF (`:3000`)** — serves the SPA (or proxies to Vite) and proxies `/api/*` to the API | +| `npm run dev:all` | Both of the above together (via `concurrently`), `VITE_DEV_SERVER_URL` already set | +| `npm run build` | Build the SPA into `server/public/`, which the BFF serves directly (no Vite dev server) | +| `npm run build:watch` | Rebuild on change — for iterating without HMR, against the BFF-served build | +| `npm run generate` | Regenerate API types from `openapi.json` | +| `npm run preview` | Preview production build | +| `npm run lint` | Check for linting errors | +| `npm run lint:fix` | Auto-fix linting errors | +| `npm run format` | Format all files with Prettier | +| `npm run format:check` | Check formatting without changes | +| `npm run test` | Run tests in watch mode | +| `npm run test:run` | Run tests once (CI mode) | +| `npm run test:ui` | Run tests with UI | +| `npm run test:coverage` | Generate coverage report | +| `npm run e2e` | Run Playwright E2E tests | +| `npm run e2e:ui` | Playwright UI mode | +| `npm run e2e:debug` | Playwright Inspector | +| `npm run e2e:install` | Install Playwright browsers | +| `npm run e2e:report` | Open last Playwright report | ## Internationalization (i18n) diff --git a/package-lock.json b/package-lock.json index 2fa2782b..01ba7054 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@vitejs/plugin-react": "^4.4.1", "@vitest/coverage-v8": "^4.1.9", "@vitest/ui": "^4.1.9", + "concurrently": "^10.0.5", "eslint": "^9.25.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.1", @@ -6157,9 +6158,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", - "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -6229,9 +6230,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -6248,11 +6249,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -6343,9 +6344,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -6585,6 +6586,100 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -7070,9 +7165,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.339", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", - "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -10452,10 +10547,13 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "license": "MIT" + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/npm-run-path": { "version": "6.0.0", @@ -11158,9 +11256,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", + "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -11920,6 +12018,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -12206,6 +12314,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -12836,6 +12957,16 @@ "node": ">=18" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -13249,9 +13380,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", @@ -13743,6 +13874,37 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index ccde7026..a297e2a8 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,13 @@ "node": ">=22.22.1" }, "scripts": { + "predev": "npm ls --depth=0 >/dev/null 2>&1 || (echo 'Dependencies missing or out of date at repo root — run: npm install' >&2 && exit 1)", "dev": "vite", "dev:e2e": "vite --base=/ --port 5173 --strictPort", + "predev:bff": "npm ls --prefix server --depth=0 >/dev/null 2>&1 || (echo 'Dependencies missing or out of date in server/ — run: npm install --prefix server' >&2 && exit 1)", + "dev:bff": "VITE_DEV_SERVER_URL=http://localhost:5173 npm run dev --prefix server", + "predev:all": "npm run predev --silent && npm run generate", + "dev:all": "concurrently -n BFF,WEB -c blue,green \"npm run dev:bff\" \"npm run dev\"", "build": "npm run generate && tsc -b && vite build", "build:watch": "vite build --watch", "preview": "vite preview", @@ -68,6 +73,7 @@ "@vitejs/plugin-react": "^4.4.1", "@vitest/coverage-v8": "^4.1.9", "@vitest/ui": "^4.1.9", + "concurrently": "^10.0.5", "eslint": "^9.25.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.1", diff --git a/server/package-lock.json b/server/package-lock.json index 9ee9df8a..6d744f60 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,6 +11,7 @@ "@fastify/compress": "^9.2.0", "@fastify/cookie": "^11.1.2", "@fastify/csrf-protection": "^8.0.1", + "@fastify/http-proxy": "^11.6.1", "@fastify/redis": "^8.0.0", "@fastify/reply-from": "^12.6.4", "@fastify/static": "^10.1.2", @@ -621,6 +622,28 @@ ], "license": "MIT" }, + "node_modules/@fastify/http-proxy": { + "version": "11.6.1", + "resolved": "https://registry.npmjs.org/@fastify/http-proxy/-/http-proxy-11.6.1.tgz", + "integrity": "sha512-VhlqlN4Np0cmJByJfUIIDlTVHeYJsS0U5AMKta1gPCRKkP9u2GTWr0lYuhrUJF38kDA8n3oT8wcioZtIrL+tlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/reply-from": "^12.6.2", + "fast-querystring": "^1.1.2", + "fastify-plugin": "^6.0.0", + "ws": "^8.18.3" + } + }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "funding": [ @@ -881,9 +904,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -901,9 +921,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -921,9 +938,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -941,9 +955,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -961,9 +972,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -981,9 +989,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1954,9 +1959,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1978,9 +1980,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2002,9 +2001,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2026,9 +2022,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2143,7 +2136,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.17", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -2827,6 +2822,27 @@ "node_modules/wrappy": { "version": "1.0.2", "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/server/package.json b/server/package.json index 49c7b3b8..e0d038ab 100644 --- a/server/package.json +++ b/server/package.json @@ -19,6 +19,7 @@ "@fastify/compress": "^9.2.0", "@fastify/cookie": "^11.1.2", "@fastify/csrf-protection": "^8.0.1", + "@fastify/http-proxy": "^11.6.1", "@fastify/redis": "^8.0.0", "@fastify/reply-from": "^12.6.4", "@fastify/static": "^10.1.2", diff --git a/server/src/config.ts b/server/src/config.ts index 5439f671..5a33a973 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -75,6 +75,16 @@ export const config = { // for a non-standard layout, or to point at a temp dir in tests. publicDir: optionalUnset("PUBLIC_DIR"), + // Set to a running Vite dev server's origin (e.g. http://localhost:5173) + // to reverse-proxy everything not owned by the BFF's own routes (SPA + // shell, JS/CSS modules, HMR) to it instead of serving server/public/ — + // see plugins/vite-dev-proxy.ts, registered instead of plugins/static.ts + // when this is set. Lets the browser visit the BFF's own origin and get + // HMR, instead of visiting Vite directly (which would need PUBLIC_ORIGIN + // set to match, since origin-guard.ts checks the browser's actual + // Origin). Unset (default) = serve the built SPA as usual. + viteDevServerUrl: optionalUnset("VITE_DEV_SERVER_URL"), + // Session-revocation re-check cadence for long-lived SSE connections // (Option A from agent-output/bff-proxy-and-sse-plan.md — bounded staleness, // no pub/sub required). Revisit if instant revocation becomes a hard requirement. diff --git a/server/src/index.ts b/server/src/index.ts index 9c8d5ba5..3cf2c16b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,6 +17,7 @@ import csrfPlugin from "./plugins/csrf.js"; import redisPlugin from "./plugins/redis.js"; import sessionPlugin from "./plugins/session.js"; import staticPlugin from "./plugins/static.js"; +import viteDevProxyPlugin from "./plugins/vite-dev-proxy.js"; import appRoute from "./routes/app.js"; import changePasswordRequiredRoute from "./routes/auth/change-password-required.js"; import loginRoute from "./routes/auth/login.js"; @@ -39,7 +40,9 @@ await fastify.register(redisPlugin); await fastify.register(sessionPlugin); await fastify.register(csrfPlugin); await fastify.register(compressPlugin); -await fastify.register(staticPlugin); +// VITE_DEV_SERVER_URL set = proxy to a running Vite dev server for HMR +// (see plugins/vite-dev-proxy.ts); unset = serve the built SPA as usual. +await fastify.register(config.viteDevServerUrl ? viteDevProxyPlugin : staticPlugin); fastify.get("/healthz", async () => ({ ok: true })); diff --git a/server/src/plugins/vite-dev-proxy.ts b/server/src/plugins/vite-dev-proxy.ts new file mode 100644 index 00000000..ef78a8b0 --- /dev/null +++ b/server/src/plugins/vite-dev-proxy.ts @@ -0,0 +1,36 @@ +// Location: ./client/server/src/plugins/vite-dev-proxy.ts +// Copyright contributors to the MCP-CONTEXT-FORGE project +// SPDX-License-Identifier: Apache-2.0 +// +// Dev-only substitute for plugins/static.ts: reverse-proxies everything not +// matched by the BFF's own routes (SPA shell, JS/CSS modules, HMR) to a +// separately-running `npm run dev` Vite dev server (config.viteDevServerUrl), +// so the browser only ever talks to the BFF's own origin instead of Vite's — +// avoiding the Origin mismatch a directly-visited Vite dev server would hit +// against origin-guard.ts. Registered in index.ts instead of staticPlugin +// when VITE_DEV_SERVER_URL is set; never active in production. +// +// /api/*, /auth/*, /healthz etc. are unaffected: those are static-prefixed +// routes registered elsewhere, which find-my-way always prefers over this +// plugin's root wildcard regardless of registration order. Bare `/` is +// deliberately excluded from `routes` below (the plugin's default also +// registers that exact path) since routes/app.ts's own GET / owns the +// auth-aware redirect — registering both would collide as duplicate routes. +// +// websocket: true proxies any WS upgrade that does land on this origin, but +// Vite's HMR client connects browser->Vite directly on Vite's own port by +// default — this is a safety net, not the primary HMR path. + +import httpProxy from "@fastify/http-proxy"; +import type { FastifyInstance } from "fastify"; + +import { config } from "../config.js"; + +export default async function viteDevProxyPlugin(fastify: FastifyInstance): Promise { + await fastify.register(httpProxy, { + upstream: config.viteDevServerUrl!, + prefix: "/", + routes: ["/*"], + websocket: true, + }); +}