diff --git a/.env b/.env index 0d4fcd609..a8fd67995 100644 --- a/.env +++ b/.env @@ -2,9 +2,13 @@ NODE_ENV=development SERVER_HOST=localhost # change me to 0.0.0.0 if you want to access the server from other devices +# port offset digit (0-9) embedded in all *_PORT values below, so parallel checkouts can run without +# colliding. Run `npm run assign-ports` to write a free PORT_OFFSET to .env.local (see assign-ports.mts). +PORT_OFFSET=0 + # postgres POSTGRESQL_HOST=localhost -POSTGRESQL_PORT=5432 +POSTGRESQL_PORT=5${PORT_OFFSET}32 POSTGRESQL_USER=postgres POSTGRESQL_PASSWORD=vivid POSTGRESQL_DB=main @@ -23,7 +27,7 @@ S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= # imgproxy -IMGPROXY_PORT=6080 +IMGPROXY_PORT=6${PORT_OFFSET}80 IMGPROXY_URL=http://localhost:${IMGPROXY_PORT} IMGPROXY_KEY=943b421c9eb07c830af81030552c86009268de4e532ba2ee2eab8247c6da0881 IMGPROXY_SALT=520f986b998545b4785e0defbc4f3c1203f22de2374a3d53cb7a7fe9fea309c5 @@ -34,11 +38,11 @@ IMGPROXY_USE_S3=false DAM_SECRET=6a9e8a185b513363bc89ec0b96eed8f70c759bc86b97319f60365c4b7f8593dc # authproxy -AUTHPROXY_PORT=8000 +AUTHPROXY_PORT=8${PORT_OFFSET}00 AUTHPROXY_URL=http://${DEV_DOMAIN:-localhost}:${AUTHPROXY_PORT} # api -API_PORT=4000 +API_PORT=4${PORT_OFFSET}00 API_URL=$ADMIN_URL/api API_URL_INTERNAL=http://localhost:$API_PORT/api CORS_ALLOWED_ORIGIN="^http:\/\/(localhost|.*\.dev\.vivid-planet\.cloud|192\.168\.\d{1,3}\.\d{1,3}):\d{2,4}" @@ -52,13 +56,13 @@ BLOB_STORAGE_DRIVER="file" BLOB_STORAGE_DIRECTORY_PREFIX="starter" # admin -ADMIN_PORT=8001 +ADMIN_PORT=8${PORT_OFFSET}01 ADMIN_URL=$AUTHPROXY_URL ADMIN_URL_INTERNAL=http://localhost:$ADMIN_PORT PREVIEW_URL=http://${DEV_DOMAIN:-localhost}:${SITE_PORT} # site -SITE_PORT=3000 +SITE_PORT=3${PORT_OFFSET}00 SITE_URL=http://${DEV_DOMAIN:-localhost}:${SITE_PORT} # no gtm in dev mode NEXT_PUBLIC_GTM_ID= @@ -66,20 +70,20 @@ NEXT_PUBLIC_API_URL=$API_URL API_BASIC_AUTH_SYSTEM_USER_PASSWORD=$BASIC_AUTH_SYSTEM_USER_PASSWORD # jaegertracing -JAEGER_UI_PORT=16686 +JAEGER_UI_PORT=16${PORT_OFFSET}86 JAEGER_HOST=localhost -JAEGER_OLTP_PORT=4318 +JAEGER_OLTP_PORT=4${PORT_OFFSET}18 TRACING_ENABLED=1 VALKEY_ENABLED=false # activate valkey service in docker-compose.yml if set to true -VALKEY_PORT=6379 +VALKEY_PORT=6${PORT_OFFSET}79 VALKEY_HOST=localhost VALKEY_PASSWORD=vivid SITE_PREVIEW_SECRET=5b67e073dbc2434e # idp -IDP_PORT=8080 +IDP_PORT=8${PORT_OFFSET}80 IDP_CLIENT_ID=comet-oidc-client IDP_CLIENT_SECRET=comet-oidc-secret IDP_SSO_URL=http://${DEV_DOMAIN:-localhost}:${IDP_PORT} diff --git a/AGENTS.md b/AGENTS.md index 001927c42..ffaa6365e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,17 @@ npx dev-pm restart # Restart a service npx dev-pm shutdown # Stop all services ``` +#### Running multiple instances in parallel + +To run several checkouts at once without host port collisions, assign a free port offset before starting: + +```bash +npm run assign-ports # find a free PORT_OFFSET digit (0-9) and write it to .env.local +npm run dev +``` + +Every `*_PORT` value in `.env` embeds an offset digit via `${PORT_OFFSET}` (e.g. `API_PORT=4${PORT_OFFSET}00`), with `PORT_OFFSET=0` as the default. `assign-ports.mts` resolves each port for the digits 0-9, finds the first digit where all ports are free, and writes only `PORT_OFFSET=` to `.env.local` (leaving `.env` untouched). It runs via Node's native TypeScript support (`node assign-ports.mts`), so no build or ts-node is needed. `npm run dev` runs `dev-pm start` via `dotenv -c secrets`, which expands and exports the cascaded env (`.env.local` overrides the `PORT_OFFSET` default from `.env`) so both the Node services and the Docker containers (`docker compose` prefers real env vars over the `.env` file) bind the offset ports. + ### Building ```bash @@ -121,9 +132,9 @@ The `site-configs/` directory manages site configurations, compiled into environ ### Docker Services -- PostgreSQL (port 5432) +- PostgreSQL (port 5032) - imgproxy (port 6080) - image optimization -- Jaeger (port 16686) - distributed tracing +- Jaeger (port 16086) - distributed tracing ### Local Ports diff --git a/assign-ports.mts b/assign-ports.mts new file mode 100755 index 000000000..3c54e6c9a --- /dev/null +++ b/assign-ports.mts @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// Assigns a unique PORT_OFFSET so multiple instances of this project can run in parallel. +// The *_PORT values in .env embed the offset digit via ${PORT_OFFSET} (e.g. API_PORT=4${PORT_OFFSET}00). +// Prefers offset 0 (the .env default) and only writes a PORT_OFFSET override to .env.local when needed. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; + +const ENV_FILE = ".env"; +const ENV_LOCAL_FILE = ".env.local"; +const PLACEHOLDER = "${PORT_OFFSET}"; +const MAX_OFFSET = 9; + +function fail(message: string): never { + console.error(`Error: ${message}`); + process.exit(1); +} + +function readPortEntries(): { name: string; template: string }[] { + if (!existsSync(ENV_FILE)) { + fail(`No ${ENV_FILE} file found in ${process.cwd()}`); + } + + return readFileSync(ENV_FILE, "utf8") + .split("\n") + .map((line) => /^([A-Z_]+_PORT)=(\S+)$/.exec(line.trim())) + .filter((match) => match !== null) + .map((match) => ({ name: match[1], template: match[2] })); +} + +function portForOffset(entry: { name: string; template: string }, offset: number): number { + const value = entry.template.replaceAll(PLACEHOLDER, String(offset)); + if (!/^\d+$/.test(value)) { + fail(`${entry.name}=${entry.template} in ${ENV_FILE} does not resolve to a number (expected digits and ${PLACEHOLDER} only)`); + } + return Number(value); +} + +// Node sets SO_REUSEADDR, so a wildcard bind coexists with specific-address binds (and vice versa). +// Probe all three addresses to also catch localhost-only listeners like Vite's dev server. +async function portIsFree(port: number): Promise { + for (const host of ["0.0.0.0", "127.0.0.1", "::1"]) { + const free = await new Promise((resolve) => { + const server = createServer(); + server.once("error", (error: NodeJS.ErrnoException) => resolve(error.code !== "EADDRINUSE")); + server.once("listening", () => server.close(() => resolve(true))); + server.listen(port, host); + }); + if (!free) return false; + } + return true; +} + +async function findFreeOffset(entries: { name: string; template: string }[]): Promise { + for (let offset = 0; offset <= MAX_OFFSET; offset++) { + let conflict: { name: string; port: number } | undefined; + + for (const entry of entries) { + const port = portForOffset(entry, offset); + if (!(await portIsFree(port))) { + conflict = { name: entry.name, port }; + break; + } + } + + if (!conflict) return offset; + + console.error(`Offset ${offset} has a port conflict (${conflict.name}=${conflict.port} in use), trying next...`); + } + + fail(`Could not find a free port offset (tried 0-${MAX_OFFSET})`); +} + +function writeEnvLocal(entries: { name: string; template: string }[], offset: number): void { + const existingLines = existsSync(ENV_LOCAL_FILE) ? readFileSync(ENV_LOCAL_FILE, "utf8").split("\n") : []; + + // Drop any existing PORT_OFFSET and *_PORT assignments from .env.local to avoid duplicates/stale overrides + const names = new Set(["PORT_OFFSET", ...entries.map((entry) => entry.name)]); + const keptLines = existingLines.filter((line) => !names.has(line.split("=")[0].trim())); + + while (keptLines.length > 0 && keptLines[keptLines.length - 1] === "") { + keptLines.pop(); + } + + // Offset 0 means the base ports are free, so no override is written and the .env default applies + const lines = offset === 0 ? keptLines : [...keptLines, `PORT_OFFSET=${offset}`]; + + if (lines.length === 0 && existingLines.length === 0) return; + + writeFileSync(ENV_LOCAL_FILE, lines.length > 0 ? `${lines.join("\n")}\n` : ""); +} + +async function main(): Promise { + const entries = readPortEntries(); + if (entries.length === 0) { + fail(`No *_PORT entries found in ${ENV_FILE}`); + } + if (!entries.some((entry) => entry.template.includes(PLACEHOLDER))) { + fail(`No *_PORT entry in ${ENV_FILE} contains ${PLACEHOLDER}, so ports cannot be offset`); + } + + const offset = await findFreeOffset(entries); + writeEnvLocal(entries, offset); + + if (offset === 0) { + console.error(`All base ports are free, using PORT_OFFSET=0 (${ENV_FILE} default applies)`); + } else { + console.error(`PORT_OFFSET=${offset} written to ${ENV_LOCAL_FILE}`); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/package.json b/package.json index 932547b3f..dda962de4 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,9 @@ "name": "starter", "private": true, "scripts": { + "assign-ports": "node assign-ports.mts", "create-site-configs-env": "dotenv -e .env.secrets -e .env.local -e .env -- npx @comet/cli inject-site-configs -f site-configs/site-configs.ts -i .env.site-configs.tpl -o .env.site-configs --base64", - "dev": "dev-pm start", + "dev": "npm run create-site-configs-env && dotenv -c secrets -- dev-pm start", "dev:auth-proxy": "dotenv -- ./node_modules/.bin/oauth2-proxy --cookie-secret=$(head -c 16 /dev/random | base64)", "dev:auth-provider": "dotenv -- dev-oidc-provider", "setup-project-files": "node setup-project-files.js", diff --git a/site/cache-handler.ts b/site/cache-handler.ts index e33ae2630..83fd869c8 100644 --- a/site/cache-handler.ts +++ b/site/cache-handler.ts @@ -8,7 +8,7 @@ if (!VALKEY_HOST) { throw new Error("VALKEY_HOST is required"); } -const VALKEY_PORT = parseInt(process.env.VALKEY_PORT || "6379", 10); +const VALKEY_PORT = parseInt(process.env.VALKEY_PORT || "6079", 10); const VALKEY_PASSWORD = process.env.VALKEY_PASSWORD; if (!VALKEY_PASSWORD) {