fix(selfhost): derive local port URLs from env (MUL-2506) (#2939)

* fix(selfhost): derive local port URLs from env

* fix(selfhost): derive local script URLs
This commit is contained in:
YOMXXX
2026-05-24 13:05:53 +08:00
committed by GitHub
parent 660e27b981
commit bfb7c85491
13 changed files with 340 additions and 27 deletions

View File

@@ -21,14 +21,23 @@ APP_ENV=
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# Optional aliases for the local/self-host backend port. If one is set, it
# takes precedence over PORT in compose, Makefile, and installer helpers.
# BACKEND_PORT=8080
# API_PORT=8080
# SERVER_PORT=8080
# Prometheus metrics are disabled by default. When enabled, bind to loopback
# unless you protect the listener with private networking, allowlists, or
# proxy auth. Do not expose this endpoint through the public app/API ingress.
# HTTP request metrics start accumulating only when this listener is enabled.
# METRICS_ADDR=127.0.0.1:9090
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when the daemon reaches the API through a different URL.
# MULTICA_SERVER_URL=ws://localhost:8080/ws
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when the app's public URL differs from local frontend.
# MULTICA_APP_URL=http://localhost:3000
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers. Leave unset behind a same-origin reverse proxy or for plain
@@ -91,7 +100,9 @@ SMTP_TLS_INSECURE=false
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when your OAuth callback URL differs from local frontend.
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
@@ -121,7 +132,9 @@ COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when uploads are served through a different public URL.
# LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
@@ -170,9 +183,11 @@ GITHUB_WEBHOOK_SECRET=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
# FRONTEND_ORIGIN=http://localhost:3000
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# Only set explicitly if frontend and backend are on different domains.
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=

View File

@@ -29,6 +29,9 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Test self-host env derivation
run: bash scripts/selfhost-config.test.sh
- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON

View File

@@ -12,7 +12,7 @@ POSTGRES_DB ?= multica
POSTGRES_USER ?= multica
POSTGRES_PASSWORD ?= multica
POSTGRES_PORT ?= 5432
PORT ?= 8080
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
FRONTEND_PORT ?= 3000
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)
@@ -21,6 +21,7 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT)
export

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { resolveRemoteApiUrl } from "./runtime-urls";
describe("resolveRemoteApiUrl", () => {
it("prefers REMOTE_API_URL when explicitly configured", () => {
expect(
resolveRemoteApiUrl({
REMOTE_API_URL: "http://backend:8080",
NEXT_PUBLIC_API_URL: "http://localhost:19000",
PORT: "18080",
}),
).toBe("http://backend:8080");
});
it("uses NEXT_PUBLIC_API_URL when REMOTE_API_URL is unset", () => {
expect(
resolveRemoteApiUrl({
NEXT_PUBLIC_API_URL: "http://localhost:19000",
PORT: "18080",
}),
).toBe("http://localhost:19000");
});
it("derives localhost backend URL from PORT when no API URL is set", () => {
expect(resolveRemoteApiUrl({ PORT: "19080" })).toBe("http://localhost:19080");
});
it("supports explicit backend port aliases before PORT", () => {
expect(resolveRemoteApiUrl({ BACKEND_PORT: "28080", PORT: "19080" })).toBe(
"http://localhost:28080",
);
expect(resolveRemoteApiUrl({ API_PORT: "38080", PORT: "19080" })).toBe(
"http://localhost:38080",
);
expect(resolveRemoteApiUrl({ SERVER_PORT: "48080", PORT: "19080" })).toBe(
"http://localhost:48080",
);
});
it("prefers backend port aliases by documented precedence", () => {
expect(
resolveRemoteApiUrl({
BACKEND_PORT: "28080",
API_PORT: "38080",
SERVER_PORT: "48080",
PORT: "19080",
}),
).toBe("http://localhost:28080");
expect(
resolveRemoteApiUrl({
API_PORT: "38080",
SERVER_PORT: "48080",
PORT: "19080",
}),
).toBe("http://localhost:38080");
expect(resolveRemoteApiUrl({ SERVER_PORT: "48080", PORT: "19080" })).toBe(
"http://localhost:48080",
);
});
it("ignores whitespace-only backend URL values", () => {
expect(
resolveRemoteApiUrl({
REMOTE_API_URL: " ",
NEXT_PUBLIC_API_URL: " ",
BACKEND_PORT: " ",
API_PORT: " ",
SERVER_PORT: " ",
PORT: "19080",
}),
).toBe("http://localhost:19080");
expect(resolveRemoteApiUrl({ PORT: " " })).toBe("http://localhost:8080");
});
it("falls back to the historical backend port when no env is configured", () => {
expect(resolveRemoteApiUrl({})).toBe("http://localhost:8080");
});
});

View File

@@ -0,0 +1,18 @@
type RuntimeEnv = Record<string, string | undefined>;
export function resolveRemoteApiUrl(env: RuntimeEnv): string {
const explicitRemote = env.REMOTE_API_URL?.trim();
if (explicitRemote) return explicitRemote;
const publicApi = env.NEXT_PUBLIC_API_URL?.trim();
if (publicApi) return publicApi;
const port =
env.BACKEND_PORT?.trim() ||
env.API_PORT?.trim() ||
env.SERVER_PORT?.trim() ||
env.PORT?.trim();
if (port) return `http://localhost:${port}`;
return "http://localhost:8080";
}

View File

@@ -1,11 +1,12 @@
import type { NextConfig } from "next";
import { config } from "dotenv";
import { resolve } from "path";
import { resolveRemoteApiUrl } from "./config/runtime-urls";
// Load root .env so REMOTE_API_URL is available to next.config.ts
config({ path: resolve(__dirname, "../../.env") });
const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080";
const remoteApiUrl = resolveRemoteApiUrl(process.env);
const docsUrl = process.env.DOCS_URL || "http://localhost:4000";
// Parse hostnames from CORS_ALLOWED_ORIGINS so that Next.js dev server

View File

@@ -13,8 +13,8 @@
# # Edit .env — change JWT_SECRET at minimum
# docker compose -f docker-compose.selfhost.yml up -d
#
# Frontend: http://localhost:3000
# Backend: http://localhost:8080 (also used by CLI/daemon)
# Frontend: http://localhost:${FRONTEND_PORT:-3000}
# Backend: http://localhost:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}
name: multica
@@ -40,7 +40,7 @@ services:
postgres:
condition: service_healthy
ports:
- "127.0.0.1:${PORT:-8080}:8080"
- "127.0.0.1:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}:8080"
volumes:
- backend_uploads:/app/data/uploads
environment:
@@ -48,7 +48,7 @@ services:
PORT: "8080"
METRICS_ADDR: ${METRICS_ADDR:-}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT:-3000}}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
@@ -59,7 +59,7 @@ services:
SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:${FRONTEND_PORT:-3000}/auth/callback}
S3_BUCKET: ${S3_BUCKET:-}
S3_REGION: ${S3_REGION:-us-west-2}
CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-}
@@ -68,7 +68,7 @@ services:
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:${FRONTEND_PORT:-3000}}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}

View File

@@ -18,13 +18,8 @@ set -a
. "$ENV_FILE"
set +a
POSTGRES_DB="${POSTGRES_DB:-multica}"
POSTGRES_USER="${POSTGRES_USER:-multica}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
PORT="${PORT:-8080}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-http://localhost:${FRONTEND_PORT}}"
export PLAYWRIGHT_BASE_URL
# shellcheck disable=SC1091
. scripts/local-env.sh
BACKEND_PID=""
FRONTEND_PID=""

View File

@@ -40,6 +40,9 @@ set -a
. "$ENV_FILE"
set +a
# shellcheck disable=SC1091
. scripts/local-env.sh
# ---------- Install dependencies ----------
if [ ! -d node_modules ]; then
echo "==> Installing dependencies..."

View File

@@ -30,6 +30,46 @@ function Test-CommandExists {
$null -ne (Get-Command $Name -ErrorAction SilentlyContinue)
}
function Get-EnvFileValue {
param(
[string]$Path,
[string]$Name,
[string]$Default
)
if (-not (Test-Path $Path)) {
return $Default
}
$prefix = "$Name="
$line = Get-Content $Path |
Where-Object { $_.StartsWith($prefix) } |
Select-Object -Last 1
if (-not $line) {
return $Default
}
$value = $line.Substring($prefix.Length).Trim().Trim('"').Trim("'")
if ([string]::IsNullOrWhiteSpace($value)) {
return $Default
}
return $value
}
function Get-SelfHostBackendPort {
foreach ($name in @("BACKEND_PORT", "API_PORT", "SERVER_PORT", "PORT")) {
$value = Get-EnvFileValue -Path (Join-Path $InstallDir ".env") -Name $name -Default ""
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value
}
}
return "8080"
}
function Get-SelfHostFrontendPort {
return Get-EnvFileValue -Path (Join-Path $InstallDir ".env") -Name "FRONTEND_PORT" -Default "3000"
}
function Get-LatestVersion {
try {
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/multica-ai/multica/releases/latest" -ErrorAction Stop
@@ -386,10 +426,11 @@ function Install-Server {
docker compose -f docker-compose.selfhost.yml up -d
Write-Info "Waiting for backend to be ready..."
$backendPort = Get-SelfHostBackendPort
$ready = $false
for ($i = 1; $i -le 45; $i++) {
try {
$null = Invoke-WebRequest -Uri "http://localhost:8080/health" -UseBasicParsing -TimeoutSec 2
$null = Invoke-WebRequest -Uri "http://localhost:$backendPort/health" -UseBasicParsing -TimeoutSec 2
$ready = $true
break
} catch {
@@ -451,8 +492,10 @@ function Start-LocalInstall {
Write-Host " [OK] Multica server is running and CLI is ready!" -ForegroundColor Green
Write-Host " ============================================" -ForegroundColor Green
Write-Host ""
Write-Host " Frontend: http://localhost:3000"
Write-Host " Backend: http://localhost:8080"
$frontendPort = Get-SelfHostFrontendPort
$backendPort = Get-SelfHostBackendPort
Write-Host " Frontend: http://localhost:$frontendPort"
Write-Host " Backend: http://localhost:$backendPort"
Write-Host " Server at: $InstallDir"
Write-Host ""
Write-Host " Next: configure your CLI to connect"

View File

@@ -41,6 +41,46 @@ fail() { printf "${BOLD}${RED}✗ %s${RESET}\n" "$*" >&2; exit 1; }
command_exists() { command -v "$1" >/dev/null 2>&1; }
env_file_value() {
local file="$1"
local key="$2"
local default="$3"
local line value
line="$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n 1 || true)"
if [ -z "$line" ]; then
printf "%s" "$default"
return
fi
value="${line#*=}"
value="${value%$'\r'}"
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
if [ -z "$value" ]; then
printf "%s" "$default"
else
printf "%s" "$value"
fi
}
selfhost_backend_port() {
local file="${1:-.env}"
local value
for key in BACKEND_PORT API_PORT SERVER_PORT PORT; do
value="$(env_file_value "$file" "$key" "")"
if [ -n "$value" ]; then
printf "%s" "$value"
return
fi
done
printf "8080"
}
selfhost_frontend_port() {
env_file_value "${1:-.env}" "FRONTEND_PORT" "3000"
}
detect_os() {
case "$(uname -s)" in
Darwin) OS="darwin" ;;
@@ -339,9 +379,11 @@ setup_server() {
# Wait for health check
info "Waiting for backend to be ready..."
local backend_port
backend_port="$(selfhost_backend_port .env)"
local ready=false
for i in $(seq 1 45); do
if curl -sf http://localhost:8080/health >/dev/null 2>&1; then
if curl -sf "http://localhost:${backend_port}/health" >/dev/null 2>&1; then
ready=true
break
fi
@@ -403,8 +445,11 @@ run_with_server() {
printf "${BOLD}${GREEN} ✓ Multica server is running and CLI is ready!${RESET}\n"
printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"
printf "\n"
printf " ${BOLD}Frontend:${RESET} http://localhost:3000\n"
printf " ${BOLD}Backend:${RESET} http://localhost:8080\n"
local frontend_port backend_port
frontend_port="$(selfhost_frontend_port "$INSTALL_DIR/.env")"
backend_port="$(selfhost_backend_port "$INSTALL_DIR/.env")"
printf " ${BOLD}Frontend:${RESET} http://localhost:%s\n" "$frontend_port"
printf " ${BOLD}Backend:${RESET} http://localhost:%s\n" "$backend_port"
printf " ${BOLD}Server at:${RESET} %s\n" "$INSTALL_DIR"
printf "\n"
printf " ${BOLD}Next: configure your CLI to connect${RESET}\n"

20
scripts/local-env.sh Normal file
View File

@@ -0,0 +1,20 @@
# Shared local development env derivation. Source this after loading .env.
POSTGRES_DB="${POSTGRES_DB:-multica}"
POSTGRES_USER="${POSTGRES_USER:-multica}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
PORT="${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT}}"
MULTICA_APP_URL="${MULTICA_APP_URL:-${FRONTEND_ORIGIN}}"
GOOGLE_REDIRECT_URI="${GOOGLE_REDIRECT_URI:-${FRONTEND_ORIGIN}/auth/callback}"
MULTICA_SERVER_URL="${MULTICA_SERVER_URL:-ws://localhost:${PORT}/ws}"
LOCAL_UPLOAD_BASE_URL="${LOCAL_UPLOAD_BASE_URL:-http://localhost:${PORT}}"
PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-${FRONTEND_ORIGIN}}"
export POSTGRES_DB POSTGRES_USER POSTGRES_PORT
export PORT FRONTEND_PORT FRONTEND_ORIGIN
export MULTICA_APP_URL GOOGLE_REDIRECT_URI MULTICA_SERVER_URL LOCAL_UPLOAD_BASE_URL
export PLAYWRIGHT_BASE_URL

87
scripts/selfhost-config.test.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
require_config() {
local config=$1
local expected=$2
if ! grep -Fq "$expected" <<<"$config"; then
echo "Missing expected docker compose config value:"
echo " $expected"
exit 1
fi
}
require_env() {
local output=$1
local expected=$2
if ! grep -Fxq "$expected" <<<"$output"; then
echo "Missing expected derived env value:"
echo " $expected"
echo "Observed:"
echo "$output"
exit 1
fi
}
config="$(
FRONTEND_PORT=3100 BACKEND_PORT=9100 docker compose \
--env-file .env.example \
-f docker-compose.selfhost.yml \
config
)"
require_config "$config" 'published: "3100"'
require_config "$config" 'published: "9100"'
require_config "$config" 'FRONTEND_ORIGIN: http://localhost:3100'
require_config "$config" 'GOOGLE_REDIRECT_URI: http://localhost:3100/auth/callback'
require_config "$config" 'MULTICA_APP_URL: http://localhost:3100'
for script in scripts/dev.sh scripts/check.sh; do
if ! grep -Fq '. scripts/local-env.sh' "$script"; then
echo "$script must source scripts/local-env.sh for shared local env derivation."
exit 1
fi
done
tmp_env="$(mktemp)"
trap 'rm -f "$tmp_env"' EXIT
sed 's/^FRONTEND_PORT=.*/FRONTEND_PORT=3100/' .env.example >"$tmp_env"
printf '\nBACKEND_PORT=9100\n' >>"$tmp_env"
local_env="$(
env -i PATH="$PATH" bash -c '
set -euo pipefail
env_file=$1
set -a
# shellcheck disable=SC1090
. "$env_file"
set +a
# shellcheck disable=SC1091
. scripts/local-env.sh
printf "%s\n" \
"PORT=${PORT}" \
"FRONTEND_PORT=${FRONTEND_PORT}" \
"FRONTEND_ORIGIN=${FRONTEND_ORIGIN}" \
"MULTICA_APP_URL=${MULTICA_APP_URL}" \
"GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI}" \
"MULTICA_SERVER_URL=${MULTICA_SERVER_URL}" \
"LOCAL_UPLOAD_BASE_URL=${LOCAL_UPLOAD_BASE_URL}" \
"PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}"
' _ "$tmp_env"
)"
require_env "$local_env" 'PORT=9100'
require_env "$local_env" 'FRONTEND_PORT=3100'
require_env "$local_env" 'FRONTEND_ORIGIN=http://localhost:3100'
require_env "$local_env" 'MULTICA_APP_URL=http://localhost:3100'
require_env "$local_env" 'GOOGLE_REDIRECT_URI=http://localhost:3100/auth/callback'
require_env "$local_env" 'MULTICA_SERVER_URL=ws://localhost:9100/ws'
require_env "$local_env" 'LOCAL_UPLOAD_BASE_URL=http://localhost:9100'
require_env "$local_env" 'PLAYWRIGHT_BASE_URL=http://localhost:3100'
echo "self-host env derivation ok"