feat: single-port architecture and browser warning fixes (#390)
This commit is contained in:
+2
-2
@@ -5,7 +5,7 @@
|
||||
# docker build -t kandev:latest .
|
||||
#
|
||||
# Run:
|
||||
# docker run -p 8080:8080 -p 3000:3000 -v kandev-data:/data kandev:latest
|
||||
# docker run -p 8080:8080 -v kandev-data:/data kandev:latest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Go builder — compile kandev + agentctl binaries
|
||||
@@ -110,7 +110,7 @@ RUN chown -R kandev:kandev /app /data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 8080 3000
|
||||
EXPOSE 8080
|
||||
|
||||
# tini as PID 1 for signal handling; entrypoint handles privilege drop
|
||||
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
|
||||
|
||||
@@ -3,6 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -383,6 +386,7 @@ type routeParams struct {
|
||||
msgCreator *messageCreatorAdapter
|
||||
secretsSvc *secrets.Service
|
||||
secretStore secrets.SecretStore
|
||||
webInternalURL string
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
@@ -401,6 +405,33 @@ func registerRoutes(p routeParams) {
|
||||
p.router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kandev", "mode": "websocket+http"})
|
||||
})
|
||||
|
||||
if p.webInternalURL != "" {
|
||||
target, err := url.Parse(p.webInternalURL)
|
||||
if err != nil {
|
||||
p.log.Error("Invalid web internal URL, skipping reverse proxy", zap.String("url", p.webInternalURL), zap.Error(err))
|
||||
} else {
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
proxy.FlushInterval = -1
|
||||
// Custom error handler to prevent the default behavior of panicking
|
||||
// with http.ErrAbortHandler when the upstream connection fails or the
|
||||
// client disconnects. Without this, Gin's recovery middleware catches
|
||||
// the panic and logs a full (harmless but noisy) stack trace.
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
p.log.Debug("Web proxy error", zap.Error(err))
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}
|
||||
p.router.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") || path == "/ws" || path == "/health" {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
p.log.Info("Web reverse proxy enabled", zap.String("target", p.webInternalURL))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerTaskRoutes registers all task-related HTTP and WebSocket routes.
|
||||
|
||||
@@ -432,6 +432,7 @@ func buildHTTPServer(
|
||||
msgCreator: msgCreator,
|
||||
secretsSvc: secrets.NewService(repos.Secrets, log),
|
||||
secretStore: repos.Secrets,
|
||||
webInternalURL: cfg.Server.WebInternalURL,
|
||||
log: log,
|
||||
})
|
||||
|
||||
|
||||
@@ -50,10 +50,11 @@ func (c *Config) ResolvedDataDir() string {
|
||||
|
||||
// ServerConfig holds HTTP server configuration.
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
ReadTimeout int `mapstructure:"readTimeout"` // in seconds
|
||||
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
ReadTimeout int `mapstructure:"readTimeout"` // in seconds
|
||||
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
|
||||
WebInternalURL string `mapstructure:"webInternalUrl"`
|
||||
}
|
||||
|
||||
// DatabaseConfig holds database connection configuration.
|
||||
@@ -197,6 +198,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.port", 8080)
|
||||
v.SetDefault("server.readTimeout", 30)
|
||||
v.SetDefault("server.writeTimeout", 30)
|
||||
v.SetDefault("server.webInternalUrl", "")
|
||||
|
||||
// DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev/data
|
||||
v.SetDefault("dataDir", "")
|
||||
@@ -312,6 +314,7 @@ func LoadWithPath(configPath string) (*Config, error) {
|
||||
_ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST")
|
||||
_ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT")
|
||||
_ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL")
|
||||
_ = v.BindEnv("server.webInternalUrl", "KANDEV_WEB_INTERNAL_URL")
|
||||
_ = v.BindEnv("dataDir", "KANDEV_DATA_DIR")
|
||||
_ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL")
|
||||
_ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE")
|
||||
|
||||
+1
-2
@@ -28,7 +28,7 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
|
||||
KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true",
|
||||
};
|
||||
const backendEnv = buildBackendEnv({ ports, extra });
|
||||
const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true });
|
||||
const webEnv = buildWebEnv({ ports, debug: true });
|
||||
const logLevel =
|
||||
process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info";
|
||||
|
||||
@@ -36,7 +36,6 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
|
||||
header: "dev mode: using local repo",
|
||||
ports,
|
||||
dbPath,
|
||||
includeMcp: true,
|
||||
logLevel,
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -174,6 +174,7 @@ async function prepareReleaseBundle({
|
||||
const backendEnv = {
|
||||
...process.env,
|
||||
KANDEV_SERVER_PORT: String(actualBackendPort),
|
||||
KANDEV_WEB_INTERNAL_URL: `http://localhost:${actualWebPort}`,
|
||||
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
|
||||
KANDEV_DATABASE_PATH: dbPath,
|
||||
KANDEV_LOG_LEVEL: logLevel,
|
||||
@@ -182,7 +183,6 @@ async function prepareReleaseBundle({
|
||||
const webEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
KANDEV_API_BASE_URL: backendUrl,
|
||||
NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl,
|
||||
PORT: String(actualWebPort),
|
||||
};
|
||||
(webEnv as Record<string, string>).NODE_ENV = "production";
|
||||
@@ -264,6 +264,6 @@ export async function runRelease({ version, backendPort, webPort }: RunOptions):
|
||||
quiet: true,
|
||||
});
|
||||
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
|
||||
console.log(`[kandev] web ready at ${webUrl}`);
|
||||
openBrowser(webUrl);
|
||||
console.log("[kandev] ready at " + prepared.backendUrl);
|
||||
openBrowser(prepared.backendUrl);
|
||||
}
|
||||
|
||||
+9
-22
@@ -67,6 +67,7 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
KANDEV_SERVER_PORT: String(ports.backendPort),
|
||||
KANDEV_WEB_INTERNAL_URL: `http://localhost:${ports.webPort}`,
|
||||
KANDEV_AGENT_STANDALONE_PORT: String(ports.agentctlPort),
|
||||
KANDEV_AGENT_MCP_SERVER_PORT: String(ports.mcpPort),
|
||||
...(logLevel ? { KANDEV_LOG_LEVEL: logLevel } : {}),
|
||||
@@ -76,8 +77,6 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv {
|
||||
|
||||
export type WebEnvOptions = {
|
||||
ports: PortConfig;
|
||||
/** Include MCP URL environment variables (used in dev/start modes) */
|
||||
includeMcp?: boolean;
|
||||
/** Set NODE_ENV to production */
|
||||
production?: boolean;
|
||||
/** Enable debug mode (NEXT_PUBLIC_KANDEV_DEBUG) */
|
||||
@@ -91,25 +90,18 @@ export type WebEnvOptions = {
|
||||
* @returns Environment object for the web process
|
||||
*/
|
||||
export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv {
|
||||
const { ports, includeMcp = false, production = false, debug = false } = options;
|
||||
const { ports, production = false, debug = false } = options;
|
||||
|
||||
// Server-side env vars use localhost (SSR runs on same machine as backend)
|
||||
// Client-side uses NEXT_PUBLIC_*_PORT to build URLs dynamically from window.location.hostname
|
||||
// This allows accessing the app from any device (iPhone, Tailscale, etc.)
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
// Server-side: full localhost URL for SSR
|
||||
// Server-side: full localhost URL for SSR fetches (Next.js SSR → Go backend)
|
||||
KANDEV_API_BASE_URL: ports.backendUrl,
|
||||
// Client-side: only pass ports, client builds URL from current hostname
|
||||
// Client-side: port injection for dev mode (browser on :3000, API on :8080).
|
||||
// In production, the backend reverse-proxies Next.js so the client uses same-origin.
|
||||
NEXT_PUBLIC_KANDEV_API_PORT: String(ports.backendPort),
|
||||
PORT: String(ports.webPort),
|
||||
};
|
||||
|
||||
if (includeMcp) {
|
||||
env.KANDEV_MCP_SERVER_URL = ports.mcpUrl;
|
||||
env.NEXT_PUBLIC_KANDEV_MCP_PORT = String(ports.mcpPort);
|
||||
}
|
||||
|
||||
if (production) {
|
||||
(env as Record<string, string>).NODE_ENV = "production";
|
||||
}
|
||||
@@ -127,8 +119,6 @@ export type StartupInfoOptions = {
|
||||
ports: PortConfig;
|
||||
/** Database file path */
|
||||
dbPath?: string;
|
||||
/** Whether to log MCP-related ports */
|
||||
includeMcp?: boolean;
|
||||
/** Log level being used */
|
||||
logLevel?: string;
|
||||
};
|
||||
@@ -137,15 +127,12 @@ export type StartupInfoOptions = {
|
||||
* Logs a unified startup info block to the console.
|
||||
*/
|
||||
export function logStartupInfo(options: StartupInfoOptions): void {
|
||||
const { header, ports, dbPath, includeMcp = false, logLevel } = options;
|
||||
const { header, ports, dbPath, logLevel } = options;
|
||||
console.log(`[kandev] ${header}`);
|
||||
console.log("[kandev] backend port:", ports.backendPort);
|
||||
console.log("[kandev] web port:", ports.webPort);
|
||||
console.log("[kandev] url: http://localhost:" + ports.backendPort);
|
||||
console.log("[kandev] agentctl port:", ports.agentctlPort);
|
||||
if (includeMcp) {
|
||||
console.log("[kandev] mcp port:", ports.mcpPort);
|
||||
console.log("[kandev] mcp url:", ports.mcpUrl);
|
||||
}
|
||||
console.log("[kandev] mcp port:", ports.mcpPort);
|
||||
console.log("[kandev] mcp url:", ports.mcpUrl);
|
||||
if (dbPath) {
|
||||
console.log("[kandev] db path:", dbPath);
|
||||
}
|
||||
|
||||
@@ -121,13 +121,12 @@ export async function runStart({
|
||||
...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true" } : {}),
|
||||
},
|
||||
});
|
||||
const webEnv = buildWebEnv({ ports, includeMcp: true, production: true, debug });
|
||||
const webEnv = buildWebEnv({ ports, production: true, debug });
|
||||
|
||||
logStartupInfo({
|
||||
header: "start mode: using local build",
|
||||
ports,
|
||||
dbPath,
|
||||
includeMcp: true,
|
||||
logLevel,
|
||||
});
|
||||
|
||||
@@ -168,6 +167,6 @@ export async function runStart({
|
||||
});
|
||||
|
||||
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
|
||||
console.log(`[kandev] web ready at ${webUrl}`);
|
||||
openBrowser(webUrl);
|
||||
console.log("[kandev] ready at " + ports.backendUrl);
|
||||
openBrowser(ports.backendUrl);
|
||||
}
|
||||
|
||||
+5
-26
@@ -24,31 +24,15 @@ export const viewport: Viewport = {
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract port from URL string (e.g., "http://localhost:8080" -> "8080")
|
||||
*/
|
||||
function extractPort(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.port || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Runtime URL configuration for the browser client.
|
||||
// KANDEV_PUBLIC_URL: full external URL when behind a reverse proxy / ingress
|
||||
// (e.g., "https://kandev.example.com") — injected as window.__KANDEV_API_BASE_URL.
|
||||
// Otherwise: extract ports so client builds URLs from window.location.hostname,
|
||||
// allowing access from any device (iPhone, Tailscale, etc.)
|
||||
const publicUrl = process.env.KANDEV_PUBLIC_URL?.replace(/\/$/, "") || null;
|
||||
const apiPort = publicUrl ? null : extractPort(process.env.KANDEV_API_BASE_URL ?? "");
|
||||
const mcpPort = extractPort(process.env.KANDEV_MCP_SERVER_URL ?? "");
|
||||
// API port injection for dev mode (browser opens at web port, API on backend port).
|
||||
// In production single-port mode, this is not needed — the client uses
|
||||
// window.location.origin (same-origin, works for any domain / reverse proxy).
|
||||
const apiPort = process.env.NEXT_PUBLIC_KANDEV_API_PORT ?? null;
|
||||
const debugMode = process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true";
|
||||
|
||||
return (
|
||||
@@ -57,16 +41,11 @@ export default function RootLayout({
|
||||
<meta name="apple-mobile-web-app-title" content="KanDev" />
|
||||
</head>
|
||||
<body className="antialiased font-sans">
|
||||
{publicUrl || apiPort || mcpPort || debugMode ? (
|
||||
// Inject runtime config for production bundles.
|
||||
// publicUrl: full base URL for reverse-proxy deployments (same-origin API)
|
||||
// ports: client builds URLs using window.location.hostname + port
|
||||
{apiPort || debugMode ? (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: [
|
||||
publicUrl ? `window.__KANDEV_API_BASE_URL = ${JSON.stringify(publicUrl)};` : "",
|
||||
apiPort ? `window.__KANDEV_API_PORT = ${JSON.stringify(apiPort)};` : "",
|
||||
mcpPort ? `window.__KANDEV_MCP_PORT = ${JSON.stringify(mcpPort)};` : "",
|
||||
debugMode ? `window.__KANDEV_DEBUG = true;` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -69,21 +69,28 @@ function initTerminalInstance(
|
||||
} catch (e) {
|
||||
log("Initial fit failed:", e);
|
||||
}
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => {
|
||||
log("WebGL context lost");
|
||||
webglAddon.dispose();
|
||||
refs.webglAddonRef.current = null;
|
||||
});
|
||||
terminal.loadAddon(webglAddon);
|
||||
refs.webglAddonRef.current = webglAddon;
|
||||
log("WebGL addon loaded");
|
||||
} catch (e) {
|
||||
log("WebGL failed, using canvas:", e);
|
||||
}
|
||||
refs.xtermRef.current = terminal;
|
||||
refs.fitAddonRef.current = fitAddon;
|
||||
// Defer WebGL addon loading to the next animation frame so the initial
|
||||
// synchronous work (Terminal + FitAddon + open) stays within the browser's
|
||||
// frame budget and avoids "Violation: 'setTimeout' handler took Xms" warnings.
|
||||
requestAnimationFrame(() => {
|
||||
const term = refs.xtermRef.current;
|
||||
if (!term || refs.webglAddonRef.current) return;
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => {
|
||||
log("WebGL context lost");
|
||||
webglAddon.dispose();
|
||||
refs.webglAddonRef.current = null;
|
||||
});
|
||||
term.loadAddon(webglAddon);
|
||||
refs.webglAddonRef.current = webglAddon;
|
||||
log("WebGL addon loaded");
|
||||
} catch (e) {
|
||||
log("WebGL failed, using canvas:", e);
|
||||
}
|
||||
});
|
||||
// Expose buffer reader on the container for e2e tests (xterm renders to
|
||||
// canvas so text isn't accessible in the DOM).
|
||||
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
|
||||
@@ -278,7 +285,12 @@ function connectWebSocket({
|
||||
attachAddonRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
if (
|
||||
wsRef.current.readyState === WebSocket.OPEN ||
|
||||
wsRef.current.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label);
|
||||
@@ -402,7 +414,15 @@ export function useWebSocketConnection({
|
||||
attachAddonRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
// Only close if the connection is actually open or has completed
|
||||
// opening. Closing a CONNECTING WebSocket triggers a browser warning
|
||||
// ("WebSocket is closed before the connection is established").
|
||||
if (
|
||||
wsRef.current.readyState === WebSocket.OPEN ||
|
||||
wsRef.current.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,6 +227,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
|
||||
env: {
|
||||
...(process.env as unknown as Record<string, string>),
|
||||
KANDEV_API_BASE_URL: baseUrl,
|
||||
NEXT_PUBLIC_KANDEV_API_PORT: String(backendPort),
|
||||
PORT: String(frontendPort),
|
||||
HOSTNAME: "localhost",
|
||||
NODE_ENV: "production",
|
||||
|
||||
@@ -106,11 +106,11 @@ export { expect } from "@playwright/test";
|
||||
async function setupPage(page: Page, backend: BackendContext, seedData: SeedData): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({
|
||||
backendUrl,
|
||||
backendPort,
|
||||
repositoryId,
|
||||
agentProfileId,
|
||||
}: {
|
||||
backendUrl: string;
|
||||
backendPort: string;
|
||||
repositoryId: string;
|
||||
agentProfileId: string;
|
||||
}) => {
|
||||
@@ -121,10 +121,11 @@ async function setupPage(page: Page, backend: BackendContext, seedData: SeedData
|
||||
localStorage.setItem("kandev.dialog.lastAgentProfileId", JSON.stringify(agentProfileId));
|
||||
localStorage.setItem("kandev.dialog.lastBranch", JSON.stringify("main"));
|
||||
// Set the window global that getBackendConfig() reads for API/WS connections
|
||||
window.__KANDEV_API_BASE_URL = backendUrl;
|
||||
// (e2e tests run frontend and backend on separate ports, like dev mode)
|
||||
window.__KANDEV_API_PORT = backendPort;
|
||||
},
|
||||
{
|
||||
backendUrl: backend.baseUrl,
|
||||
backendPort: String(backend.port),
|
||||
repositoryId: seedData.repositoryId,
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
},
|
||||
|
||||
Vendored
+1
-5
@@ -2,12 +2,8 @@ export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// Full URL overrides (for custom domains/proxies)
|
||||
__KANDEV_API_BASE_URL?: string;
|
||||
__KANDEV_MCP_SERVER_URL?: string;
|
||||
// Port-only injection (client builds URL from window.location.hostname + port)
|
||||
// Port injection for dev mode (browser on web port, API on backend port)
|
||||
__KANDEV_API_PORT?: string;
|
||||
__KANDEV_MCP_PORT?: string;
|
||||
// Debug mode flag (injected at runtime by layout.tsx)
|
||||
__KANDEV_DEBUG?: boolean;
|
||||
}
|
||||
|
||||
+13
-48
@@ -1,68 +1,33 @@
|
||||
export type AppConfig = {
|
||||
apiBaseUrl: string;
|
||||
mcpServerUrl: string;
|
||||
};
|
||||
|
||||
// Default ports
|
||||
const DEFAULT_API_PORT = 8080;
|
||||
const DEFAULT_MCP_PORT = 9090;
|
||||
|
||||
// Server-side defaults (used during SSR - always localhost since SSR runs on same machine)
|
||||
const DEFAULT_API_BASE_URL = `http://localhost:${DEFAULT_API_PORT}`;
|
||||
const DEFAULT_MCP_SERVER_URL = `http://localhost:${DEFAULT_MCP_PORT}/sse`;
|
||||
// Server-side default (used during SSR - always localhost since SSR runs on same machine)
|
||||
const DEFAULT_API_BASE_URL = "http://localhost:8080";
|
||||
|
||||
export const DEBUG_UI =
|
||||
process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true" ||
|
||||
(typeof window !== "undefined" && window.__KANDEV_DEBUG === true);
|
||||
|
||||
/**
|
||||
* Build URL from current page hostname and port.
|
||||
* This allows accessing the app from any device (iPhone, Tailscale, etc.)
|
||||
* without manual configuration.
|
||||
*/
|
||||
function buildClientUrl(port: number, path = ""): string {
|
||||
const protocol = window.location.protocol === "https:" ? "https:" : "http:";
|
||||
return `${protocol}//${window.location.hostname}:${port}${path}`;
|
||||
}
|
||||
|
||||
export function getBackendConfig(): AppConfig {
|
||||
// Server-side: use env vars or localhost defaults (SSR runs on same machine as backend)
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
apiBaseUrl: process.env.KANDEV_API_BASE_URL ?? DEFAULT_API_BASE_URL,
|
||||
mcpServerUrl: process.env.KANDEV_MCP_SERVER_URL ?? DEFAULT_MCP_SERVER_URL,
|
||||
};
|
||||
}
|
||||
|
||||
// Client-side: Build URLs from current hostname + port
|
||||
// Priority:
|
||||
// 1. Explicit full URL override via window.__KANDEV_API_BASE_URL (for custom domains/proxies)
|
||||
// 2. Port from window.__KANDEV_API_PORT (injected by layout.tsx from server env)
|
||||
// 3. Port from NEXT_PUBLIC env var (build-time)
|
||||
// 4. Default port
|
||||
// All port-based options build URL from window.location.hostname
|
||||
|
||||
if (window.__KANDEV_API_BASE_URL) {
|
||||
// Full URL override - use as-is (for custom domains/proxies)
|
||||
return {
|
||||
apiBaseUrl: window.__KANDEV_API_BASE_URL,
|
||||
mcpServerUrl: window.__KANDEV_MCP_SERVER_URL || buildClientUrl(DEFAULT_MCP_PORT, "/sse"),
|
||||
};
|
||||
// Client-side URL resolution:
|
||||
// 1. Port-based URL via __KANDEV_API_PORT (dev mode: browser on :3000, API on :8080)
|
||||
// 2. Same-origin (production: Go reverse-proxies Next.js on single port)
|
||||
// Works for any hosting scenario: localhost, custom domain, Tailscale, etc.
|
||||
if (window.__KANDEV_API_PORT) {
|
||||
const port = parseInt(window.__KANDEV_API_PORT, 10);
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65535) {
|
||||
const protocol = window.location.protocol;
|
||||
return { apiBaseUrl: `${protocol}//${window.location.hostname}:${port}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Build URLs dynamically from current hostname + port
|
||||
const apiPort =
|
||||
parseInt(window.__KANDEV_API_PORT || "", 10) ||
|
||||
parseInt(process.env.NEXT_PUBLIC_KANDEV_API_PORT || "", 10) ||
|
||||
DEFAULT_API_PORT;
|
||||
|
||||
const mcpPort =
|
||||
parseInt(window.__KANDEV_MCP_PORT || "", 10) ||
|
||||
parseInt(process.env.NEXT_PUBLIC_KANDEV_MCP_PORT || "", 10) ||
|
||||
DEFAULT_MCP_PORT;
|
||||
|
||||
return {
|
||||
apiBaseUrl: buildClientUrl(apiPort),
|
||||
mcpServerUrl: buildClientUrl(mcpPort, "/sse"),
|
||||
};
|
||||
return { apiBaseUrl: window.location.origin };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
vi.mock("@/lib/config", () => ({
|
||||
getBackendConfig: () => ({
|
||||
apiBaseUrl: "http://localhost:8080",
|
||||
mcpServerUrl: "http://localhost:9090/sse",
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Convert HTTP URL to WebSocket URL
|
||||
* Convert HTTP URL to WebSocket URL.
|
||||
* http://localhost:8080 → ws://localhost:8080/ws
|
||||
* https://api.example.com → wss://api.example.com/ws
|
||||
*/
|
||||
|
||||
+14
-42
@@ -5,10 +5,10 @@ Run Kandev in a Docker container. For Kubernetes deployment, see [k8s.md](k8s.md
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 -p 3000:3000 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest
|
||||
docker run -p 8080:8080 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` in your browser.
|
||||
Open `http://localhost:8080` in your browser.
|
||||
|
||||
## Using the Pre-built Image
|
||||
|
||||
@@ -53,7 +53,7 @@ Without a volume, data is lost when the container is removed.
|
||||
Configuration is done via `KANDEV_`-prefixed environment variables:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 -p 3000:3000 \
|
||||
docker run -p 8080:8080 \
|
||||
-v kandev-data:/data \
|
||||
-e KANDEV_LOG_LEVEL=debug \
|
||||
ghcr.io/kdlbs/kandev:latest
|
||||
@@ -63,7 +63,6 @@ docker run -p 8080:8080 -p 3000:3000 \
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `KANDEV_PUBLIC_URL` | (unset) | External URL when behind a reverse proxy (e.g., `https://kandev.example.com`) |
|
||||
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
|
||||
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
|
||||
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
|
||||
@@ -76,7 +75,7 @@ docker run -p 8080:8080 -p 3000:3000 \
|
||||
To use PostgreSQL instead of SQLite:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 -p 3000:3000 \
|
||||
docker run -p 8080:8080 \
|
||||
-e KANDEV_DATABASE_DRIVER=postgres \
|
||||
-e KANDEV_DATABASE_HOST=host.docker.internal \
|
||||
-e KANDEV_DATABASE_PORT=5432 \
|
||||
@@ -86,20 +85,21 @@ docker run -p 8080:8080 -p 3000:3000 \
|
||||
ghcr.io/kdlbs/kandev:latest
|
||||
```
|
||||
|
||||
## Ports
|
||||
## Port
|
||||
|
||||
Kandev exposes a single port. The Go backend serves the API, WebSocket, and reverse-proxies the Next.js frontend — all on one port:
|
||||
|
||||
| Port | Service |
|
||||
|------|---------|
|
||||
| `8080` | Backend API + WebSocket |
|
||||
| `3000` | Web UI |
|
||||
| `8080` | API + WebSocket + Web UI |
|
||||
|
||||
Override ports by changing the published ports and passing CLI flags:
|
||||
Override the port:
|
||||
|
||||
```bash
|
||||
docker run -p 9080:9080 -p 4000:4000 \
|
||||
docker run -p 9080:9080 \
|
||||
-v kandev-data:/data \
|
||||
ghcr.io/kdlbs/kandev:latest \
|
||||
kandev start --backend-port 9080 --web-port 4000
|
||||
kandev start --backend-port 9080
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
@@ -112,7 +112,6 @@ services:
|
||||
image: ghcr.io/kdlbs/kandev:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- kandev-data:/data
|
||||
restart: unless-stopped
|
||||
@@ -133,7 +132,6 @@ services:
|
||||
image: ghcr.io/kdlbs/kandev:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- kandev-data:/data
|
||||
environment:
|
||||
@@ -170,20 +168,7 @@ volumes:
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
When running Kandev behind a reverse proxy (nginx, Traefik, Caddy) that serves both the API and web UI on the same domain, set `KANDEV_PUBLIC_URL` so the frontend routes API calls through the proxy instead of trying to reach port 8080 directly:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 -p 3000:3000 \
|
||||
-v kandev-data:/data \
|
||||
-e KANDEV_PUBLIC_URL=https://kandev.example.com \
|
||||
ghcr.io/kdlbs/kandev:latest
|
||||
```
|
||||
|
||||
Your reverse proxy should route:
|
||||
- `/api/*` and `/ws` → `http://kandev:8080` (backend)
|
||||
- `/*` → `http://kandev:3000` (web UI)
|
||||
|
||||
With WebSocket support enabled (for `/ws`).
|
||||
Since Kandev serves everything on a single port, a reverse proxy only needs to forward all traffic to port 8080. No extra environment variables are needed — the frontend automatically uses `window.location.origin` to reach the API.
|
||||
|
||||
### Docker Compose with Caddy
|
||||
|
||||
@@ -193,8 +178,6 @@ services:
|
||||
image: ghcr.io/kdlbs/kandev:latest
|
||||
volumes:
|
||||
- kandev-data:/data
|
||||
environment:
|
||||
KANDEV_PUBLIC_URL: "https://kandev.example.com"
|
||||
restart: unless-stopped
|
||||
|
||||
caddy:
|
||||
@@ -216,18 +199,7 @@ Example `Caddyfile`:
|
||||
|
||||
```
|
||||
kandev.example.com {
|
||||
handle /api/* {
|
||||
reverse_proxy kandev:8080
|
||||
}
|
||||
handle /ws {
|
||||
reverse_proxy kandev:8080
|
||||
}
|
||||
handle /health {
|
||||
reverse_proxy kandev:8080
|
||||
}
|
||||
handle {
|
||||
reverse_proxy kandev:3000
|
||||
}
|
||||
reverse_proxy kandev:8080
|
||||
}
|
||||
```
|
||||
|
||||
@@ -236,7 +208,7 @@ kandev.example.com {
|
||||
By default, `KANDEV_DOCKER_ENABLED=false` inside the container. To enable Docker-based agent execution, mount the Docker socket:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 -p 3000:3000 \
|
||||
docker run -p 8080:8080 \
|
||||
-v kandev-data:/data \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e KANDEV_DOCKER_ENABLED=true \
|
||||
|
||||
+7
-15
@@ -56,7 +56,7 @@ kubectl logs -l app=kandev -f
|
||||
| Resource | File | Purpose |
|
||||
|----------|------|---------|
|
||||
| Deployment | `deployment.yaml` | Single-replica pod running backend + web |
|
||||
| Service | `service.yaml` | ClusterIP exposing ports 8080 (API) and 3000 (UI) |
|
||||
| Service | `service.yaml` | ClusterIP exposing port 8080 |
|
||||
| ConfigMap | `configmap.yaml` | Non-sensitive environment configuration |
|
||||
| PVC | `pvc.yaml` | 10Gi persistent volume for SQLite + worktrees |
|
||||
| Ingress | `ingress.yaml` | Example ingress with WebSocket support |
|
||||
@@ -66,22 +66,15 @@ kubectl logs -l app=kandev -f
|
||||
**Port-forward** (quickest for testing):
|
||||
|
||||
```bash
|
||||
kubectl port-forward svc/kandev 3000:3000 8080:8080
|
||||
# Open http://localhost:3000
|
||||
kubectl port-forward svc/kandev 8080:8080
|
||||
# Open http://localhost:8080
|
||||
```
|
||||
|
||||
**Ingress**: Edit `k8s/ingress.yaml` to set your domain, then apply. The ingress routes `/api` and `/ws` to the backend (port 8080) and everything else to the web UI (port 3000).
|
||||
**Ingress**: Edit `k8s/ingress.yaml` to set your domain, then apply. The ingress routes all traffic to the backend on port 8080 (the Go backend reverse-proxies the Next.js frontend internally).
|
||||
|
||||
### Custom Domain / Reverse Proxy
|
||||
|
||||
When serving Kandev behind an ingress with a custom domain (e.g., `https://kandev.example.com`), set `KANDEV_PUBLIC_URL` so the frontend knows to use the ingress URL instead of trying to reach the backend on port 8080 directly:
|
||||
|
||||
```yaml
|
||||
# In k8s/configmap.yaml
|
||||
KANDEV_PUBLIC_URL: "https://kandev.example.com"
|
||||
```
|
||||
|
||||
Without this, the browser tries to connect to `https://kandev.example.com:8080/api/...` which doesn't exist — only the ingress port (443) is exposed externally.
|
||||
No extra configuration is needed. The frontend automatically uses `window.location.origin` to reach the API, which works with any domain, reverse proxy, or ingress setup.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -91,8 +84,7 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper).
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `KANDEV_PUBLIC_URL` | (unset) | External URL when behind a reverse proxy (e.g., `https://kandev.example.com`) |
|
||||
| `KANDEV_SERVER_PORT` | `8080` | Backend API port |
|
||||
| `KANDEV_SERVER_PORT` | `8080` | Server port (API + WebSocket + Web UI) |
|
||||
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
|
||||
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
|
||||
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
|
||||
@@ -155,7 +147,7 @@ spec:
|
||||
|
||||
## Health Checks
|
||||
|
||||
The deployment includes both probes on the backend `/health` endpoint:
|
||||
The deployment includes both probes on the `/health` endpoint:
|
||||
|
||||
- **Liveness probe**: Restarts the pod if the backend becomes unresponsive (30s interval, 3 failures)
|
||||
- **Readiness probe**: Removes the pod from service during startup or issues (10s interval, 3 failures)
|
||||
|
||||
@@ -122,9 +122,9 @@ Extends the backend fixture with:
|
||||
|
||||
- **`apiClient`** (worker-scoped) — HTTP client for seeding data via the backend API
|
||||
- **`seedData`** (worker-scoped) — creates a workspace, discovers the default workflow and its steps
|
||||
- **`testPage`** (per-test) — Playwright page with a browser context whose `baseURL` points to the worker's frontend, plus `localStorage` pre-configured:
|
||||
- `kandev.onboarding.completed = "true"` — skips onboarding
|
||||
- `window.__KANDEV_API_BASE_URL` — points to the worker's backend (client-side override)
|
||||
- **`testPage`** (per-test) — Playwright page with a browser context whose `baseURL` points to the worker's frontend, pre-configured with:
|
||||
- `kandev.onboarding.completed = "true"` in localStorage — skips onboarding
|
||||
- `window.__KANDEV_API_PORT` injected via `addInitScript` — points to the worker's backend port
|
||||
|
||||
### API client (`helpers/api-client.ts`)
|
||||
|
||||
|
||||
@@ -16,8 +16,3 @@ data:
|
||||
|
||||
# Logging — auto-detects K8s and uses JSON format
|
||||
KANDEV_LOG_LEVEL: info
|
||||
|
||||
# Public URL — set when behind a reverse proxy / ingress that serves both
|
||||
# web and API on the same domain. Without this, the UI tries to reach the
|
||||
# backend API on port 8080 directly, which fails behind a proxy.
|
||||
# KANDEV_PUBLIC_URL: "https://kandev.example.com"
|
||||
|
||||
@@ -24,9 +24,6 @@ spec:
|
||||
- name: backend
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
- name: web
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: kandev-config
|
||||
|
||||
+1
-24
@@ -18,33 +18,10 @@ spec:
|
||||
- host: kandev.example.com
|
||||
http:
|
||||
paths:
|
||||
# Backend API + WebSocket
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: kandev
|
||||
port:
|
||||
name: backend
|
||||
- path: /ws
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: kandev
|
||||
port:
|
||||
name: backend
|
||||
- path: /health
|
||||
pathType: Exact
|
||||
backend:
|
||||
service:
|
||||
name: kandev
|
||||
port:
|
||||
name: backend
|
||||
# Web UI (catch-all)
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: kandev
|
||||
port:
|
||||
name: web
|
||||
name: http
|
||||
|
||||
+1
-5
@@ -9,11 +9,7 @@ spec:
|
||||
selector:
|
||||
app: kandev
|
||||
ports:
|
||||
- name: backend
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: backend
|
||||
protocol: TCP
|
||||
- name: web
|
||||
port: 3000
|
||||
targetPort: web
|
||||
protocol: TCP
|
||||
|
||||
Reference in New Issue
Block a user