fix: replace per-path proxy with cookie-based catch-all routing

The /session/{id} URL prefix collided with OpenCode's internal
/session/{slug} SPA routes, causing a blank page. Now /c/{id} is
a thin entry point that sets a session cookie and redirects to /,
where the SPA loads at root with its router working correctly.

This also replaces ~50 individual per-path proxy route handlers
with a single /{path:path} catch-all, and simplifies the Caddyfile
from ~180 lines to ~17.
This commit is contained in:
2026-02-16 10:40:17 +01:00
parent 9683cf280b
commit 5a89f46e3d
3 changed files with 51 additions and 518 deletions

View File

@@ -52,7 +52,7 @@ try: up
sleep 1; \ sleep 1; \
done && \ done && \
echo "" && \ echo "" && \
echo "Opening http://localhost:8080/session/$$SESSION_ID" && \ echo "Opening http://localhost:8080/c/$$SESSION_ID" && \
(xdg-open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \ (xdg-open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \ open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
echo "Visit: http://localhost:8080/session/$$SESSION_ID") echo "Visit: http://localhost:8080/c/$$SESSION_ID")

View File

@@ -2,187 +2,16 @@
# Using HTTP for local development (no SSL warnings) # Using HTTP for local development (no SSL warnings)
# Main web interface - HTTP only for development # Main web interface - HTTP only for development
http://localhost { :80 {
# API endpoints for session management # API endpoints for session management (strip /api prefix)
handle /api/* { handle /api/* {
uri strip_prefix /api uri strip_prefix /api
reverse_proxy session-manager:8000 reverse_proxy session-manager:8000
} }
# OpenCode internal session API (without session_id in path) # Everything else goes to session-manager (handles /c/{id} entry
# Must be BEFORE /session/{session_id}* to match first # point and cookie-based proxy to OpenCode containers)
handle /session { handle {
reverse_proxy session-manager:8000 reverse_proxy session-manager:8000
} }
}
# Session-specific routing - proxy to session manager for dynamic routing
handle /session/{session_id}* {
reverse_proxy session-manager:8000
}
# OpenCode SPA runtime requests - route based on session cookie
handle /global/* {
reverse_proxy session-manager:8000
}
handle /assets/* {
reverse_proxy session-manager:8000
}
handle /provider/* {
reverse_proxy session-manager:8000
}
handle /provider {
reverse_proxy session-manager:8000
}
handle /project {
reverse_proxy session-manager:8000
}
handle /path {
reverse_proxy session-manager:8000
}
handle /find/* {
reverse_proxy session-manager:8000
}
handle /file {
reverse_proxy session-manager:8000
}
handle /file/* {
reverse_proxy session-manager:8000
}
# Additional OpenCode API endpoints for root-path operation
handle /agent {
reverse_proxy session-manager:8000
}
handle /agent/* {
reverse_proxy session-manager:8000
}
handle /config {
reverse_proxy session-manager:8000
}
handle /config/* {
reverse_proxy session-manager:8000
}
handle /model {
reverse_proxy session-manager:8000
}
handle /model/* {
reverse_proxy session-manager:8000
}
handle /thread/* {
reverse_proxy session-manager:8000
}
handle /chat/* {
reverse_proxy session-manager:8000
}
handle /tree {
reverse_proxy session-manager:8000
}
handle /tree/* {
reverse_proxy session-manager:8000
}
handle /conversation {
reverse_proxy session-manager:8000
}
handle /conversation/* {
reverse_proxy session-manager:8000
}
handle /project/* {
reverse_proxy session-manager:8000
}
# OpenCode communication endpoints for message sending
handle /command {
reverse_proxy session-manager:8000
}
handle /command/* {
reverse_proxy session-manager:8000
}
handle /mcp {
reverse_proxy session-manager:8000
}
handle /mcp/* {
reverse_proxy session-manager:8000
}
handle /lsp {
reverse_proxy session-manager:8000
}
handle /lsp/* {
reverse_proxy session-manager:8000
}
handle /vcs {
reverse_proxy session-manager:8000
}
handle /vcs/* {
reverse_proxy session-manager:8000
}
handle /permission {
reverse_proxy session-manager:8000
}
handle /permission/* {
reverse_proxy session-manager:8000
}
handle /question {
reverse_proxy session-manager:8000
}
handle /question/* {
reverse_proxy session-manager:8000
}
handle /event {
reverse_proxy session-manager:8000
}
handle /event/* {
reverse_proxy session-manager:8000
}
handle /status {
reverse_proxy session-manager:8000
}
handle /status/* {
reverse_proxy session-manager:8000
}
# Health check
handle /health {
reverse_proxy session-manager:8000
}
# Static files and main interface (fallback)
handle /* {
try_files {path} {path}/ /index.html
file_server
}
}

View File

@@ -1,10 +1,9 @@
import os import os
import re
import time import time
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, HTTPException, Request, Response
from starlette.responses import StreamingResponse from starlette.responses import RedirectResponse, StreamingResponse
import httpx import httpx
from session_manager import session_manager from session_manager import session_manager
@@ -26,249 +25,29 @@ def get_session_from_cookie(request: Request) -> str:
if not session_id: if not session_id:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="No active session - please access via /session/{id}/ first", detail="No active session - please access via /c/{id} first",
) )
return session_id return session_id
@router.api_route("/global/{path:path}", methods=ALL_METHODS) @router.get("/c/{session_id}")
async def proxy_global_to_session(request: Request, path: str): @router.get("/c/{session_id}/{path:path}")
session_id = get_session_from_cookie(request) async def enter_session(request: Request, session_id: str, path: str = ""):
return await proxy_to_session(request, session_id, f"global/{path}") """Entry point: set session cookie and redirect to root."""
session = await session_manager.get_session(session_id)
if not session or session.status != "running":
@router.api_route("/assets/{path:path}", methods=ALL_METHODS) raise HTTPException(
async def proxy_assets_to_session(request: Request, path: str): status_code=404, detail="Session not found or not running"
session_id = get_session_from_cookie(request) )
return await proxy_to_session(request, session_id, f"assets/{path}") resp = RedirectResponse(url="/", status_code=302)
resp.set_cookie(
key="lovdata_session",
@router.api_route("/provider/{path:path}", methods=ALL_METHODS) value=session_id,
async def proxy_provider_path_to_session(request: Request, path: str): httponly=True,
session_id = get_session_from_cookie(request) samesite="lax",
return await proxy_to_session(request, session_id, f"provider/{path}") max_age=86400,
)
return resp
@router.api_route("/provider", methods=ALL_METHODS)
async def proxy_provider_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "provider")
@router.api_route("/project", methods=ALL_METHODS)
async def proxy_project_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "project")
@router.api_route("/path", methods=ALL_METHODS)
async def proxy_path_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "path")
@router.api_route("/find/{path:path}", methods=ALL_METHODS)
async def proxy_find_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"find/{path}")
@router.api_route("/file", methods=ALL_METHODS)
async def proxy_file_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "file")
@router.api_route("/file/{path:path}", methods=ALL_METHODS)
async def proxy_file_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"file/{path}")
# Additional OpenCode API endpoints for root-path operation
@router.api_route("/project/{path:path}", methods=ALL_METHODS)
async def proxy_project_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"project/{path}")
@router.api_route("/agent", methods=ALL_METHODS)
async def proxy_agent_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "agent")
@router.api_route("/agent/{path:path}", methods=ALL_METHODS)
async def proxy_agent_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"agent/{path}")
@router.api_route("/config", methods=ALL_METHODS)
async def proxy_config_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "config")
@router.api_route("/config/{path:path}", methods=ALL_METHODS)
async def proxy_config_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"config/{path}")
@router.api_route("/model", methods=ALL_METHODS)
async def proxy_model_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "model")
@router.api_route("/model/{path:path}", methods=ALL_METHODS)
async def proxy_model_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"model/{path}")
@router.api_route("/thread/{path:path}", methods=ALL_METHODS)
async def proxy_thread_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"thread/{path}")
@router.api_route("/chat/{path:path}", methods=ALL_METHODS)
async def proxy_chat_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"chat/{path}")
@router.api_route("/tree", methods=ALL_METHODS)
async def proxy_tree_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "tree")
@router.api_route("/tree/{path:path}", methods=ALL_METHODS)
async def proxy_tree_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"tree/{path}")
@router.api_route("/conversation", methods=ALL_METHODS)
async def proxy_conversation_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "conversation")
@router.api_route("/conversation/{path:path}", methods=ALL_METHODS)
async def proxy_conversation_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"conversation/{path}")
# OpenCode session and communication endpoints for message sending
@router.api_route("/command", methods=ALL_METHODS)
async def proxy_command_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "command")
@router.api_route("/command/{path:path}", methods=ALL_METHODS)
async def proxy_command_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"command/{path}")
@router.api_route("/mcp", methods=ALL_METHODS)
async def proxy_mcp_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "mcp")
@router.api_route("/mcp/{path:path}", methods=ALL_METHODS)
async def proxy_mcp_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"mcp/{path}")
@router.api_route("/lsp", methods=ALL_METHODS)
async def proxy_lsp_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "lsp")
@router.api_route("/lsp/{path:path}", methods=ALL_METHODS)
async def proxy_lsp_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"lsp/{path}")
@router.api_route("/vcs", methods=ALL_METHODS)
async def proxy_vcs_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "vcs")
@router.api_route("/vcs/{path:path}", methods=ALL_METHODS)
async def proxy_vcs_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"vcs/{path}")
@router.api_route("/permission", methods=ALL_METHODS)
async def proxy_permission_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "permission")
@router.api_route("/permission/{path:path}", methods=ALL_METHODS)
async def proxy_permission_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"permission/{path}")
@router.api_route("/question", methods=ALL_METHODS)
async def proxy_question_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "question")
@router.api_route("/question/{path:path}", methods=ALL_METHODS)
async def proxy_question_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"question/{path}")
@router.api_route("/event", methods=ALL_METHODS)
async def proxy_event_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "event")
@router.api_route("/event/{path:path}", methods=ALL_METHODS)
async def proxy_event_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"event/{path}")
@router.api_route("/status", methods=ALL_METHODS)
async def proxy_status_to_session(request: Request):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, "status")
@router.api_route("/status/{path:path}", methods=ALL_METHODS)
async def proxy_status_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"status/{path}")
# OpenCode internal session endpoint (different from our container sessions)
# Must be defined BEFORE /session/{session_id}/{path} to match first
@router.api_route("/session", methods=ALL_METHODS)
async def proxy_internal_session_to_session(request: Request):
session_id = get_session_from_cookie(request)
# Proxy the request directly, preserving query params
path = "session"
return await proxy_to_session(request, session_id, path)
def _is_sse_request(request: Request, path: str) -> bool: def _is_sse_request(request: Request, path: str) -> bool:
@@ -282,31 +61,19 @@ def _is_sse_request(request: Request, path: str) -> bool:
return False return False
@router.api_route("/session/{session_id}/{path:path}", methods=ALL_METHODS) @router.api_route("/{path:path}", methods=ALL_METHODS)
async def proxy_to_session(request: Request, session_id: str, path: str): async def proxy_root_to_container(request: Request, path: str):
"""Catch-all: proxy everything to the container identified by cookie."""
session_id = get_session_from_cookie(request)
return await _proxy_to_container(request, session_id, path)
async def _proxy_to_container(request: Request, session_id: str, path: str):
start_time = time.time() start_time = time.time()
with RequestContext(): with RequestContext():
log_request(
request.method,
f"/session/{session_id}/{path}",
200,
0,
operation="proxy_start",
session_id=session_id,
)
session = await session_manager.get_session(session_id) session = await session_manager.get_session(session_id)
if not session or session.status != "running": if not session or session.status != "running":
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method,
f"/session/{session_id}/{path}",
404,
duration_ms,
session_id=session_id,
error="Session not found or not running",
)
raise HTTPException( raise HTTPException(
status_code=404, detail="Session not found or not running" status_code=404, detail="Session not found or not running"
) )
@@ -335,7 +102,7 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
if _is_sse_request(request, path): if _is_sse_request(request, path):
return await _proxy_sse(request, session_id, path, url, headers, body, start_time) return await _proxy_sse(request, session_id, path, url, headers, body, start_time)
# --- Buffered path (original behaviour) --- # --- Buffered path ---
try: try:
log_session_operation( log_session_operation(
session_id, "proxy_request", method=request.method, path=path session_id, "proxy_request", method=request.method, path=path
@@ -351,44 +118,17 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
duration_ms = (time.time() - start_time) * 1000 duration_ms = (time.time() - start_time) * 1000
log_request( log_request(
request.method, request.method,
f"/session/{session_id}/{path}", f"/{path}",
response.status_code, response.status_code,
duration_ms, duration_ms,
session_id=session_id, session_id=session_id,
operation="proxy_complete", operation="proxy_complete",
) )
log_security_event(
"proxy_access",
"info",
session_id=session_id,
method=request.method,
path=path,
status_code=response.status_code,
)
content = response.content
response_headers = dict(response.headers)
content_type = response.headers.get("content-type", "")
if "text/html" in content_type:
try:
html = content.decode("utf-8")
session_prefix = f"/session/{session_id}"
html = re.sub(r'src="/', f'src="{session_prefix}/', html)
html = re.sub(r'href="/', f'href="{session_prefix}/', html)
html = re.sub(r'content="/', f'content="{session_prefix}/', html)
content = html.encode("utf-8")
response_headers["content-length"] = str(len(content))
except UnicodeDecodeError:
pass
resp = Response( resp = Response(
content=content, content=response.content,
status_code=response.status_code, status_code=response.status_code,
headers=response_headers, headers=dict(response.headers),
) )
resp.set_cookie( resp.set_cookie(
key="lovdata_session", key="lovdata_session",
@@ -402,20 +142,8 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
except httpx.TimeoutException as e: except httpx.TimeoutException as e:
duration_ms = (time.time() - start_time) * 1000 duration_ms = (time.time() - start_time) * 1000
log_request( log_request(
request.method, request.method, f"/{path}", 504, duration_ms,
f"/session/{session_id}/{path}", session_id=session_id, error="timeout",
504,
duration_ms,
session_id=session_id,
error="timeout",
)
log_security_event(
"proxy_timeout",
"warning",
session_id=session_id,
method=request.method,
path=path,
error=str(e),
) )
raise HTTPException( raise HTTPException(
status_code=504, detail="Request to session container timed out" status_code=504, detail="Request to session container timed out"
@@ -423,20 +151,8 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
except httpx.RequestError as e: except httpx.RequestError as e:
duration_ms = (time.time() - start_time) * 1000 duration_ms = (time.time() - start_time) * 1000
log_request( log_request(
request.method, request.method, f"/{path}", 502, duration_ms,
f"/session/{session_id}/{path}", session_id=session_id, error=str(e),
502,
duration_ms,
session_id=session_id,
error=str(e),
)
log_security_event(
"proxy_connection_error",
"error",
session_id=session_id,
method=request.method,
path=path,
error=str(e),
) )
raise HTTPException( raise HTTPException(
status_code=502, status_code=502,
@@ -458,11 +174,6 @@ async def _proxy_sse(
session_id, "proxy_sse_stream", method=request.method, path=path session_id, "proxy_sse_stream", method=request.method, path=path
) )
# We need to keep the httpx stream context alive for the lifetime of the
# StreamingResponse. Starlette calls our async generator and only closes
# it when the client disconnects, so we enter the context manager inside
# the generator and exit on cleanup.
async def event_generator(): async def event_generator():
try: try:
async with stream_http_request( async with stream_http_request(
@@ -475,22 +186,15 @@ async def _proxy_sse(
yield chunk yield chunk
except httpx.RequestError as e: except httpx.RequestError as e:
log_security_event( log_security_event(
"proxy_sse_error", "proxy_sse_error", "error",
"error", session_id=session_id, method=request.method,
session_id=session_id, path=path, error=str(e),
method=request.method,
path=path,
error=str(e),
) )
finally: finally:
duration_ms = (time.time() - start_time) * 1000 duration_ms = (time.time() - start_time) * 1000
log_request( log_request(
request.method, request.method, f"/{path}", 200, duration_ms,
f"/session/{session_id}/{path}", session_id=session_id, operation="proxy_sse_complete",
200,
duration_ms,
session_id=session_id,
operation="proxy_sse_complete",
) )
resp = StreamingResponse( resp = StreamingResponse(