feat: single-port architecture and browser warning fixes (#390)

This commit is contained in:
Carlos Florêncio
2026-03-08 13:10:39 +00:00
committed by GitHub
parent e62498ba05
commit b48471e063
23 changed files with 144 additions and 234 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
# docker build -t kandev:latest . # docker build -t kandev:latest .
# #
# Run: # 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 # Stage 1: Go builder — compile kandev + agentctl binaries
@@ -110,7 +110,7 @@ RUN chown -R kandev:kandev /app /data
WORKDIR /app WORKDIR /app
EXPOSE 8080 3000 EXPOSE 8080
# tini as PID 1 for signal handling; entrypoint handles privilege drop # tini as PID 1 for signal handling; entrypoint handles privilege drop
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"] ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
+31
View File
@@ -3,6 +3,9 @@ package main
import ( import (
"context" "context"
"net/http" "net/http"
"net/http/httputil"
"net/url"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -383,6 +386,7 @@ type routeParams struct {
msgCreator *messageCreatorAdapter msgCreator *messageCreatorAdapter
secretsSvc *secrets.Service secretsSvc *secrets.Service
secretStore secrets.SecretStore secretStore secrets.SecretStore
webInternalURL string
log *logger.Logger log *logger.Logger
} }
@@ -401,6 +405,33 @@ func registerRoutes(p routeParams) {
p.router.GET("/health", func(c *gin.Context) { p.router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kandev", "mode": "websocket+http"}) 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. // registerTaskRoutes registers all task-related HTTP and WebSocket routes.
+1
View File
@@ -432,6 +432,7 @@ func buildHTTPServer(
msgCreator: msgCreator, msgCreator: msgCreator,
secretsSvc: secrets.NewService(repos.Secrets, log), secretsSvc: secrets.NewService(repos.Secrets, log),
secretStore: repos.Secrets, secretStore: repos.Secrets,
webInternalURL: cfg.Server.WebInternalURL,
log: log, log: log,
}) })
@@ -50,10 +50,11 @@ func (c *Config) ResolvedDataDir() string {
// ServerConfig holds HTTP server configuration. // ServerConfig holds HTTP server configuration.
type ServerConfig struct { type ServerConfig struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
ReadTimeout int `mapstructure:"readTimeout"` // in seconds ReadTimeout int `mapstructure:"readTimeout"` // in seconds
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
WebInternalURL string `mapstructure:"webInternalUrl"`
} }
// DatabaseConfig holds database connection configuration. // DatabaseConfig holds database connection configuration.
@@ -197,6 +198,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("server.port", 8080) v.SetDefault("server.port", 8080)
v.SetDefault("server.readTimeout", 30) v.SetDefault("server.readTimeout", 30)
v.SetDefault("server.writeTimeout", 30) v.SetDefault("server.writeTimeout", 30)
v.SetDefault("server.webInternalUrl", "")
// DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev/data // DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev/data
v.SetDefault("dataDir", "") v.SetDefault("dataDir", "")
@@ -312,6 +314,7 @@ func LoadWithPath(configPath string) (*Config, error) {
_ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST") _ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST")
_ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT") _ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT")
_ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL") _ = 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("dataDir", "KANDEV_DATA_DIR")
_ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL") _ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL")
_ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE") _ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE")
+1 -2
View File
@@ -28,7 +28,7 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true", KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true",
}; };
const backendEnv = buildBackendEnv({ ports, extra }); const backendEnv = buildBackendEnv({ ports, extra });
const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true }); const webEnv = buildWebEnv({ ports, debug: true });
const logLevel = const logLevel =
process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info"; 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", header: "dev mode: using local repo",
ports, ports,
dbPath, dbPath,
includeMcp: true,
logLevel, logLevel,
}); });
+3 -3
View File
@@ -174,6 +174,7 @@ async function prepareReleaseBundle({
const backendEnv = { const backendEnv = {
...process.env, ...process.env,
KANDEV_SERVER_PORT: String(actualBackendPort), KANDEV_SERVER_PORT: String(actualBackendPort),
KANDEV_WEB_INTERNAL_URL: `http://localhost:${actualWebPort}`,
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort), KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
KANDEV_DATABASE_PATH: dbPath, KANDEV_DATABASE_PATH: dbPath,
KANDEV_LOG_LEVEL: logLevel, KANDEV_LOG_LEVEL: logLevel,
@@ -182,7 +183,6 @@ async function prepareReleaseBundle({
const webEnv: NodeJS.ProcessEnv = { const webEnv: NodeJS.ProcessEnv = {
...process.env, ...process.env,
KANDEV_API_BASE_URL: backendUrl, KANDEV_API_BASE_URL: backendUrl,
NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl,
PORT: String(actualWebPort), PORT: String(actualWebPort),
}; };
(webEnv as Record<string, string>).NODE_ENV = "production"; (webEnv as Record<string, string>).NODE_ENV = "production";
@@ -264,6 +264,6 @@ export async function runRelease({ version, backendPort, webPort }: RunOptions):
quiet: true, quiet: true,
}); });
await waitForUrlReady(webUrl, webProc, healthTimeoutMs); await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
console.log(`[kandev] web ready at ${webUrl}`); console.log("[kandev] ready at " + prepared.backendUrl);
openBrowser(webUrl); openBrowser(prepared.backendUrl);
} }
+9 -22
View File
@@ -67,6 +67,7 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv {
return { return {
...process.env, ...process.env,
KANDEV_SERVER_PORT: String(ports.backendPort), KANDEV_SERVER_PORT: String(ports.backendPort),
KANDEV_WEB_INTERNAL_URL: `http://localhost:${ports.webPort}`,
KANDEV_AGENT_STANDALONE_PORT: String(ports.agentctlPort), KANDEV_AGENT_STANDALONE_PORT: String(ports.agentctlPort),
KANDEV_AGENT_MCP_SERVER_PORT: String(ports.mcpPort), KANDEV_AGENT_MCP_SERVER_PORT: String(ports.mcpPort),
...(logLevel ? { KANDEV_LOG_LEVEL: logLevel } : {}), ...(logLevel ? { KANDEV_LOG_LEVEL: logLevel } : {}),
@@ -76,8 +77,6 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv {
export type WebEnvOptions = { export type WebEnvOptions = {
ports: PortConfig; ports: PortConfig;
/** Include MCP URL environment variables (used in dev/start modes) */
includeMcp?: boolean;
/** Set NODE_ENV to production */ /** Set NODE_ENV to production */
production?: boolean; production?: boolean;
/** Enable debug mode (NEXT_PUBLIC_KANDEV_DEBUG) */ /** Enable debug mode (NEXT_PUBLIC_KANDEV_DEBUG) */
@@ -91,25 +90,18 @@ export type WebEnvOptions = {
* @returns Environment object for the web process * @returns Environment object for the web process
*/ */
export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv { 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 = { const env: NodeJS.ProcessEnv = {
...process.env, ...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, 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), NEXT_PUBLIC_KANDEV_API_PORT: String(ports.backendPort),
PORT: String(ports.webPort), PORT: String(ports.webPort),
}; };
if (includeMcp) {
env.KANDEV_MCP_SERVER_URL = ports.mcpUrl;
env.NEXT_PUBLIC_KANDEV_MCP_PORT = String(ports.mcpPort);
}
if (production) { if (production) {
(env as Record<string, string>).NODE_ENV = "production"; (env as Record<string, string>).NODE_ENV = "production";
} }
@@ -127,8 +119,6 @@ export type StartupInfoOptions = {
ports: PortConfig; ports: PortConfig;
/** Database file path */ /** Database file path */
dbPath?: string; dbPath?: string;
/** Whether to log MCP-related ports */
includeMcp?: boolean;
/** Log level being used */ /** Log level being used */
logLevel?: string; logLevel?: string;
}; };
@@ -137,15 +127,12 @@ export type StartupInfoOptions = {
* Logs a unified startup info block to the console. * Logs a unified startup info block to the console.
*/ */
export function logStartupInfo(options: StartupInfoOptions): void { 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] ${header}`);
console.log("[kandev] backend port:", ports.backendPort); console.log("[kandev] url: http://localhost:" + ports.backendPort);
console.log("[kandev] web port:", ports.webPort);
console.log("[kandev] agentctl port:", ports.agentctlPort); console.log("[kandev] agentctl port:", ports.agentctlPort);
if (includeMcp) { console.log("[kandev] mcp port:", ports.mcpPort);
console.log("[kandev] mcp port:", ports.mcpPort); console.log("[kandev] mcp url:", ports.mcpUrl);
console.log("[kandev] mcp url:", ports.mcpUrl);
}
if (dbPath) { if (dbPath) {
console.log("[kandev] db path:", dbPath); console.log("[kandev] db path:", dbPath);
} }
+3 -4
View File
@@ -121,13 +121,12 @@ export async function runStart({
...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true" } : {}), ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true" } : {}),
}, },
}); });
const webEnv = buildWebEnv({ ports, includeMcp: true, production: true, debug }); const webEnv = buildWebEnv({ ports, production: true, debug });
logStartupInfo({ logStartupInfo({
header: "start mode: using local build", header: "start mode: using local build",
ports, ports,
dbPath, dbPath,
includeMcp: true,
logLevel, logLevel,
}); });
@@ -168,6 +167,6 @@ export async function runStart({
}); });
await waitForUrlReady(webUrl, webProc, healthTimeoutMs); await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
console.log(`[kandev] web ready at ${webUrl}`); console.log("[kandev] ready at " + ports.backendUrl);
openBrowser(webUrl); openBrowser(ports.backendUrl);
} }
+5 -26
View File
@@ -24,31 +24,15 @@ export const viewport: Viewport = {
userScalable: false, 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({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
// Runtime URL configuration for the browser client. // API port injection for dev mode (browser opens at web port, API on backend port).
// KANDEV_PUBLIC_URL: full external URL when behind a reverse proxy / ingress // In production single-port mode, this is not needed — the client uses
// (e.g., "https://kandev.example.com") — injected as window.__KANDEV_API_BASE_URL. // window.location.origin (same-origin, works for any domain / reverse proxy).
// Otherwise: extract ports so client builds URLs from window.location.hostname, const apiPort = process.env.NEXT_PUBLIC_KANDEV_API_PORT ?? null;
// 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 ?? "");
const debugMode = process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true"; const debugMode = process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true";
return ( return (
@@ -57,16 +41,11 @@ export default function RootLayout({
<meta name="apple-mobile-web-app-title" content="KanDev" /> <meta name="apple-mobile-web-app-title" content="KanDev" />
</head> </head>
<body className="antialiased font-sans"> <body className="antialiased font-sans">
{publicUrl || apiPort || mcpPort || debugMode ? ( {apiPort || 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
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: [ __html: [
publicUrl ? `window.__KANDEV_API_BASE_URL = ${JSON.stringify(publicUrl)};` : "",
apiPort ? `window.__KANDEV_API_PORT = ${JSON.stringify(apiPort)};` : "", apiPort ? `window.__KANDEV_API_PORT = ${JSON.stringify(apiPort)};` : "",
mcpPort ? `window.__KANDEV_MCP_PORT = ${JSON.stringify(mcpPort)};` : "",
debugMode ? `window.__KANDEV_DEBUG = true;` : "", debugMode ? `window.__KANDEV_DEBUG = true;` : "",
] ]
.filter(Boolean) .filter(Boolean)
@@ -69,21 +69,28 @@ function initTerminalInstance(
} catch (e) { } catch (e) {
log("Initial fit failed:", 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.xtermRef.current = terminal;
refs.fitAddonRef.current = fitAddon; 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 // Expose buffer reader on the container for e2e tests (xterm renders to
// canvas so text isn't accessible in the DOM). // canvas so text isn't accessible in the DOM).
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer = (termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
@@ -278,7 +285,12 @@ function connectWebSocket({
attachAddonRef.current = null; attachAddonRef.current = null;
} }
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(); if (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CLOSING
) {
wsRef.current.close();
}
wsRef.current = null; wsRef.current = null;
} }
const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label); const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label);
@@ -402,7 +414,15 @@ export function useWebSocketConnection({
attachAddonRef.current = null; attachAddonRef.current = null;
} }
if (wsRef.current) { 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; wsRef.current = null;
} }
}; };
+1
View File
@@ -227,6 +227,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
env: { env: {
...(process.env as unknown as Record<string, string>), ...(process.env as unknown as Record<string, string>),
KANDEV_API_BASE_URL: baseUrl, KANDEV_API_BASE_URL: baseUrl,
NEXT_PUBLIC_KANDEV_API_PORT: String(backendPort),
PORT: String(frontendPort), PORT: String(frontendPort),
HOSTNAME: "localhost", HOSTNAME: "localhost",
NODE_ENV: "production", NODE_ENV: "production",
+5 -4
View File
@@ -106,11 +106,11 @@ export { expect } from "@playwright/test";
async function setupPage(page: Page, backend: BackendContext, seedData: SeedData): Promise<void> { async function setupPage(page: Page, backend: BackendContext, seedData: SeedData): Promise<void> {
await page.addInitScript( await page.addInitScript(
({ ({
backendUrl, backendPort,
repositoryId, repositoryId,
agentProfileId, agentProfileId,
}: { }: {
backendUrl: string; backendPort: string;
repositoryId: string; repositoryId: string;
agentProfileId: 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.lastAgentProfileId", JSON.stringify(agentProfileId));
localStorage.setItem("kandev.dialog.lastBranch", JSON.stringify("main")); localStorage.setItem("kandev.dialog.lastBranch", JSON.stringify("main"));
// Set the window global that getBackendConfig() reads for API/WS connections // 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, repositoryId: seedData.repositoryId,
agentProfileId: seedData.agentProfileId, agentProfileId: seedData.agentProfileId,
}, },
+1 -5
View File
@@ -2,12 +2,8 @@ export {};
declare global { declare global {
interface Window { interface Window {
// Full URL overrides (for custom domains/proxies) // Port injection for dev mode (browser on web port, API on backend port)
__KANDEV_API_BASE_URL?: string;
__KANDEV_MCP_SERVER_URL?: string;
// Port-only injection (client builds URL from window.location.hostname + port)
__KANDEV_API_PORT?: string; __KANDEV_API_PORT?: string;
__KANDEV_MCP_PORT?: string;
// Debug mode flag (injected at runtime by layout.tsx) // Debug mode flag (injected at runtime by layout.tsx)
__KANDEV_DEBUG?: boolean; __KANDEV_DEBUG?: boolean;
} }
+13 -48
View File
@@ -1,68 +1,33 @@
export type AppConfig = { export type AppConfig = {
apiBaseUrl: string; apiBaseUrl: string;
mcpServerUrl: string;
}; };
// Default ports // Server-side default (used during SSR - always localhost since SSR runs on same machine)
const DEFAULT_API_PORT = 8080; const DEFAULT_API_BASE_URL = "http://localhost: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`;
export const DEBUG_UI = export const DEBUG_UI =
process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true" || process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true" ||
(typeof window !== "undefined" && window.__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 { export function getBackendConfig(): AppConfig {
// Server-side: use env vars or localhost defaults (SSR runs on same machine as backend) // Server-side: use env vars or localhost defaults (SSR runs on same machine as backend)
if (typeof window === "undefined") { if (typeof window === "undefined") {
return { return {
apiBaseUrl: process.env.KANDEV_API_BASE_URL ?? DEFAULT_API_BASE_URL, 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 // Client-side URL resolution:
// Priority: // 1. Port-based URL via __KANDEV_API_PORT (dev mode: browser on :3000, API on :8080)
// 1. Explicit full URL override via window.__KANDEV_API_BASE_URL (for custom domains/proxies) // 2. Same-origin (production: Go reverse-proxies Next.js on single port)
// 2. Port from window.__KANDEV_API_PORT (injected by layout.tsx from server env) // Works for any hosting scenario: localhost, custom domain, Tailscale, etc.
// 3. Port from NEXT_PUBLIC env var (build-time) if (window.__KANDEV_API_PORT) {
// 4. Default port const port = parseInt(window.__KANDEV_API_PORT, 10);
// All port-based options build URL from window.location.hostname if (Number.isInteger(port) && port > 0 && port <= 65535) {
const protocol = window.location.protocol;
if (window.__KANDEV_API_BASE_URL) { return { apiBaseUrl: `${protocol}//${window.location.hostname}:${port}` };
// 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"),
};
} }
// Build URLs dynamically from current hostname + port return { apiBaseUrl: window.location.origin };
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"),
};
} }
@@ -8,7 +8,6 @@ import {
vi.mock("@/lib/config", () => ({ vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ getBackendConfig: () => ({
apiBaseUrl: "http://localhost:8080", apiBaseUrl: "http://localhost:8080",
mcpServerUrl: "http://localhost:9090/sse",
}), }),
})); }));
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Convert HTTP URL to WebSocket URL * Convert HTTP URL to WebSocket URL.
* http://localhost:8080 → ws://localhost:8080/ws * http://localhost:8080 → ws://localhost:8080/ws
* https://api.example.com → wss://api.example.com/ws * https://api.example.com → wss://api.example.com/ws
*/ */
+14 -42
View File
@@ -5,10 +5,10 @@ Run Kandev in a Docker container. For Kubernetes deployment, see [k8s.md](k8s.md
## Quick Start ## Quick Start
```bash ```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 ## 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: Configuration is done via `KANDEV_`-prefixed environment variables:
```bash ```bash
docker run -p 8080:8080 -p 3000:3000 \ docker run -p 8080:8080 \
-v kandev-data:/data \ -v kandev-data:/data \
-e KANDEV_LOG_LEVEL=debug \ -e KANDEV_LOG_LEVEL=debug \
ghcr.io/kdlbs/kandev:latest ghcr.io/kdlbs/kandev:latest
@@ -63,7 +63,6 @@ docker run -p 8080:8080 -p 3000:3000 \
| Variable | Default | Description | | 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_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) | | `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) | | `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: To use PostgreSQL instead of SQLite:
```bash ```bash
docker run -p 8080:8080 -p 3000:3000 \ docker run -p 8080:8080 \
-e KANDEV_DATABASE_DRIVER=postgres \ -e KANDEV_DATABASE_DRIVER=postgres \
-e KANDEV_DATABASE_HOST=host.docker.internal \ -e KANDEV_DATABASE_HOST=host.docker.internal \
-e KANDEV_DATABASE_PORT=5432 \ -e KANDEV_DATABASE_PORT=5432 \
@@ -86,20 +85,21 @@ docker run -p 8080:8080 -p 3000:3000 \
ghcr.io/kdlbs/kandev:latest 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 | | Port | Service |
|------|---------| |------|---------|
| `8080` | Backend API + WebSocket | | `8080` | API + WebSocket + Web UI |
| `3000` | Web UI |
Override ports by changing the published ports and passing CLI flags: Override the port:
```bash ```bash
docker run -p 9080:9080 -p 4000:4000 \ docker run -p 9080:9080 \
-v kandev-data:/data \ -v kandev-data:/data \
ghcr.io/kdlbs/kandev:latest \ ghcr.io/kdlbs/kandev:latest \
kandev start --backend-port 9080 --web-port 4000 kandev start --backend-port 9080
``` ```
## Docker Compose ## Docker Compose
@@ -112,7 +112,6 @@ services:
image: ghcr.io/kdlbs/kandev:latest image: ghcr.io/kdlbs/kandev:latest
ports: ports:
- "8080:8080" - "8080:8080"
- "3000:3000"
volumes: volumes:
- kandev-data:/data - kandev-data:/data
restart: unless-stopped restart: unless-stopped
@@ -133,7 +132,6 @@ services:
image: ghcr.io/kdlbs/kandev:latest image: ghcr.io/kdlbs/kandev:latest
ports: ports:
- "8080:8080" - "8080:8080"
- "3000:3000"
volumes: volumes:
- kandev-data:/data - kandev-data:/data
environment: environment:
@@ -170,20 +168,7 @@ volumes:
## Reverse Proxy ## 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: 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.
```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`).
### Docker Compose with Caddy ### Docker Compose with Caddy
@@ -193,8 +178,6 @@ services:
image: ghcr.io/kdlbs/kandev:latest image: ghcr.io/kdlbs/kandev:latest
volumes: volumes:
- kandev-data:/data - kandev-data:/data
environment:
KANDEV_PUBLIC_URL: "https://kandev.example.com"
restart: unless-stopped restart: unless-stopped
caddy: caddy:
@@ -216,18 +199,7 @@ Example `Caddyfile`:
``` ```
kandev.example.com { kandev.example.com {
handle /api/* { reverse_proxy kandev:8080
reverse_proxy kandev:8080
}
handle /ws {
reverse_proxy kandev:8080
}
handle /health {
reverse_proxy kandev:8080
}
handle {
reverse_proxy kandev:3000
}
} }
``` ```
@@ -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: By default, `KANDEV_DOCKER_ENABLED=false` inside the container. To enable Docker-based agent execution, mount the Docker socket:
```bash ```bash
docker run -p 8080:8080 -p 3000:3000 \ docker run -p 8080:8080 \
-v kandev-data:/data \ -v kandev-data:/data \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-e KANDEV_DOCKER_ENABLED=true \ -e KANDEV_DOCKER_ENABLED=true \
+7 -15
View File
@@ -56,7 +56,7 @@ kubectl logs -l app=kandev -f
| Resource | File | Purpose | | Resource | File | Purpose |
|----------|------|---------| |----------|------|---------|
| Deployment | `deployment.yaml` | Single-replica pod running backend + web | | 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 | | ConfigMap | `configmap.yaml` | Non-sensitive environment configuration |
| PVC | `pvc.yaml` | 10Gi persistent volume for SQLite + worktrees | | PVC | `pvc.yaml` | 10Gi persistent volume for SQLite + worktrees |
| Ingress | `ingress.yaml` | Example ingress with WebSocket support | | Ingress | `ingress.yaml` | Example ingress with WebSocket support |
@@ -66,22 +66,15 @@ kubectl logs -l app=kandev -f
**Port-forward** (quickest for testing): **Port-forward** (quickest for testing):
```bash ```bash
kubectl port-forward svc/kandev 3000:3000 8080:8080 kubectl port-forward svc/kandev 8080:8080
# Open http://localhost:3000 # 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 ### 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: 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.
```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.
## Configuration ## Configuration
@@ -91,8 +84,7 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper).
| Env Var | Default | Description | | 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` | Server port (API + WebSocket + Web UI) |
| `KANDEV_SERVER_PORT` | `8080` | Backend API port |
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) | | `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) | | `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) | | `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
@@ -155,7 +147,7 @@ spec:
## Health Checks ## 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) - **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) - **Readiness probe**: Removes the pod from service during startup or issues (10s interval, 3 failures)
+3 -3
View File
@@ -122,9 +122,9 @@ Extends the backend fixture with:
- **`apiClient`** (worker-scoped) — HTTP client for seeding data via the backend API - **`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 - **`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: - **`testPage`** (per-test) — Playwright page with a browser context whose `baseURL` points to the worker's frontend, pre-configured with:
- `kandev.onboarding.completed = "true"` — skips onboarding - `kandev.onboarding.completed = "true"` in localStorage — skips onboarding
- `window.__KANDEV_API_BASE_URL` — points to the worker's backend (client-side override) - `window.__KANDEV_API_PORT` injected via `addInitScript` — points to the worker's backend port
### API client (`helpers/api-client.ts`) ### API client (`helpers/api-client.ts`)
-5
View File
@@ -16,8 +16,3 @@ data:
# Logging — auto-detects K8s and uses JSON format # Logging — auto-detects K8s and uses JSON format
KANDEV_LOG_LEVEL: info 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"
-3
View File
@@ -24,9 +24,6 @@ spec:
- name: backend - name: backend
containerPort: 8080 containerPort: 8080
protocol: TCP protocol: TCP
- name: web
containerPort: 3000
protocol: TCP
envFrom: envFrom:
- configMapRef: - configMapRef:
name: kandev-config name: kandev-config
+1 -24
View File
@@ -18,33 +18,10 @@ spec:
- host: kandev.example.com - host: kandev.example.com
http: http:
paths: 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: / - path: /
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: kandev name: kandev
port: port:
name: web name: http
+1 -5
View File
@@ -9,11 +9,7 @@ spec:
selector: selector:
app: kandev app: kandev
ports: ports:
- name: backend - name: http
port: 8080 port: 8080
targetPort: backend targetPort: backend
protocol: TCP protocol: TCP
- name: web
port: 3000
targetPort: web
protocol: TCP