Skip to content
Open
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
24 changes: 14 additions & 10 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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}"
Expand All @@ -52,34 +56,34 @@ 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=
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}
Expand Down
15 changes: 13 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ npx dev-pm restart <service> # 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=<digit>` 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
Expand Down Expand Up @@ -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

Expand Down
115 changes: 115 additions & 0 deletions assign-ports.mts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
for (const host of ["0.0.0.0", "127.0.0.1", "::1"]) {
const free = await new Promise<boolean>((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;
}
Comment on lines +39 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use the get-port package for this, but the custom implementation is fine as well.


async function findFreeOffset(entries: { name: string; template: string }[]): Promise<number> {
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<void> {
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);
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is necessary for Docker to pick up the overridden ports. However, this sets the variables for all scripts in dev-pm config. I'd prefer only setting it for Docker. Maybe we need a dev:docker script? Also, -c secrets isn't necessary, simply dotenv should be enough.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't call dotenv here. Instead, change the docker script in the dev-pm config to set -a; . .env; . .env.local; set +a; docker compose up (we did this in Demo and projects).

"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",
Expand Down
2 changes: 1 addition & 1 deletion site/cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down