Compare commits

...

5 Commits

Author SHA1 Message Date
5a89f46e3d 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.
2026-02-16 10:40:17 +01:00
9683cf280b fix: add SSE streaming proxy and robust make try startup
The SSE proxy was buffering the entire response body with a 30s read
timeout, causing 504s on the OpenCode /global/event stream. Add a
streaming path that detects SSE requests (by Accept header or /event
path) and returns a StreamingResponse with no read timeout.

Also fix the make try target to poll the health endpoint for Docker
readiness and wait for the container to reach running status before
opening the browser.
2026-02-16 00:38:57 +01:00
fb2c1f0c60 fix: make try target auto-starts stack and uses correct API paths
The try and session targets were hitting /sessions directly instead of
/api/sessions (Caddy strips the /api prefix before proxying). Also, try
now depends on up and waits for health check before creating a session.
2026-02-16 00:18:34 +01:00
217d41d680 test: strengthen Cypress e2e tests with real API assertions
- Remove blanket uncaught:exception suppressor (API-only tests)
- Trim smoke test to single infra-verification assertion
- Rewrite health test with strict status/field assertions, no failOnStatusCode
- Add session CRUD tests (create, get, list, delete, 404 cases, cleanup)
- Use Cypress.env('API_URL') instead of baseUrl to avoid blocking smoke tests
- Remove unused main and type fields from package.json
2026-02-15 23:57:48 +01:00
991080ae2b test: add initial Cypress e2e test infrastructure
Smoke tests for verifying Cypress runs, plus basic API tests
for health and sessions endpoints.
2026-02-15 23:05:56 +01:00
11 changed files with 2526 additions and 505 deletions

View File

@@ -29,14 +29,30 @@ logs:
# Create a new session and display its info # Create a new session and display its info
session: session:
@echo "Creating new session..." @echo "Creating new session..."
@curl -s -X POST http://localhost:8080/sessions | jq . @curl -s -X POST http://localhost:8080/api/sessions | jq .
# Try the web interface - creates a session and opens it # Try the web interface - starts stack, creates a session and opens it
try: try: up
@echo "Creating session and opening web interface..." @echo "Waiting for services to be ready (Docker daemon can take ~30s)..."
@SESSION_ID=$$(curl -s -X POST http://localhost:8080/sessions | jq -r '.session_id') && \ @for i in $$(seq 1 60); do \
echo "Session created: $$SESSION_ID" && \ STATUS=$$(curl -sf http://localhost:8080/api/health 2>/dev/null | jq -r '.docker // false') && \
echo "Opening http://localhost:8080/session/$$SESSION_ID" && \ [ "$$STATUS" = "true" ] && break; \
xdg-open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \ printf '.'; \
open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \ sleep 1; \
echo "Visit: http://localhost:8080/session/$$SESSION_ID" done
@echo ""
@echo "Creating session..."
@SESSION_ID=$$(curl -s -X POST http://localhost:8080/api/sessions | jq -r '.session_id') && \
echo "Session $$SESSION_ID created, waiting for container to start..." && \
for i in $$(seq 1 30); do \
S=$$(curl -sf http://localhost:8080/api/sessions/$$SESSION_ID 2>/dev/null | jq -r '.status // "unknown"') && \
[ "$$S" = "running" ] && break; \
[ "$$S" = "error" ] && echo "Container failed to start" && exit 1; \
printf '.'; \
sleep 1; \
done && \
echo "" && \
echo "Opening http://localhost:8080/c/$$SESSION_ID" && \
(xdg-open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
echo "Visit: http://localhost:8080/c/$$SESSION_ID")

12
cypress.config.js Normal file
View File

@@ -0,0 +1,12 @@
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
supportFile: 'cypress/support/e2e.js',
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
defaultCommandTimeout: 10000,
requestTimeout: 10000,
video: false,
screenshotOnRunFailure: true,
},
});

View File

@@ -0,0 +1,21 @@
/// <reference types="cypress" />
const api = () => Cypress.env('API_URL');
describe('Health API', () => {
it('GET /api/health returns status and required fields', () => {
cy.request(`${api()}/api/health`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('status');
expect(response.body.status).to.be.oneOf([
'healthy',
'degraded',
'unhealthy',
]);
expect(response.body).to.have.property('docker');
expect(response.body).to.have.property('active_sessions');
expect(response.body).to.have.property('timestamp');
expect(response.body).to.have.property('resource_limits');
});
});
});

View File

@@ -0,0 +1,97 @@
/// <reference types="cypress" />
const api = () => Cypress.env('API_URL');
describe('Sessions API', () => {
const createdSessions = [];
afterEach(() => {
createdSessions.splice(0).forEach((id) => {
cy.request({
method: 'DELETE',
url: `${api()}/api/sessions/${id}`,
failOnStatusCode: false,
});
});
});
describe('GET /api/sessions', () => {
it('returns 200 with an array', () => {
cy.request(`${api()}/api/sessions`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.be.an('array');
});
});
});
describe('POST /api/sessions', () => {
it('creates a session with expected fields', () => {
cy.request('POST', `${api()}/api/sessions`).then((response) => {
expect(response.status).to.be.oneOf([200, 201]);
expect(response.body).to.have.property('session_id');
expect(response.body).to.have.property('auth_token');
expect(response.body).to.have.property('status');
createdSessions.push(response.body.session_id);
});
});
});
describe('GET /api/sessions/:id', () => {
it('returns the created session', () => {
cy.request('POST', `${api()}/api/sessions`).then((createRes) => {
createdSessions.push(createRes.body.session_id);
const id = createRes.body.session_id;
cy.request(`${api()}/api/sessions/${id}`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('session_id', id);
});
});
});
it('returns 404 for nonexistent session', () => {
cy.request({
url: `${api()}/api/sessions/nonexistent-id-000`,
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(404);
});
});
});
describe('DELETE /api/sessions/:id', () => {
it('deletes a session', () => {
cy.request('POST', `${api()}/api/sessions`).then((createRes) => {
const id = createRes.body.session_id;
cy.request('DELETE', `${api()}/api/sessions/${id}`).then(
(response) => {
expect(response.status).to.eq(200);
expect(response.body).to.have.property('message');
}
);
});
});
it('returns 404 for nonexistent session', () => {
cy.request({
method: 'DELETE',
url: `${api()}/api/sessions/nonexistent-id-000`,
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(404);
});
});
});
describe('POST /api/cleanup', () => {
it('returns 200 with cleanup message', () => {
cy.request('POST', `${api()}/api/cleanup`).then((response) => {
expect(response.status).to.eq(200);
expect(response.body)
.to.have.property('message')
.that.includes('Cleanup completed');
});
});
});
});

7
cypress/e2e/smoke.cy.js Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="cypress" />
describe('Cypress Infrastructure Smoke Test', () => {
it('should execute a basic assertion', () => {
expect(true).to.be.true;
});
});

6
cypress/support/e2e.js Normal file
View File

@@ -0,0 +1,6 @@
// Cypress E2E support file
// Base URL for API tests. Override with CYPRESS_API_URL env var.
// Not set as Cypress baseUrl to avoid server-reachability checks
// that would block offline tests (smoke).
Cypress.env('API_URL', Cypress.env('API_URL') || 'http://localhost');

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
}
}

2203
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "lovdata-chat",
"version": "1.0.0",
"description": "A web-based chat interface that allows users to interact with Large Language Models (LLMs) equipped with Norwegian legal research tools from the Lovdata MCP server.",
"directories": {
"doc": "docs"
},
"scripts": {
"test": "cypress run",
"cypress:open": "cypress open",
"cypress:run": "cypress run"
},
"repository": {
"type": "git",
"url": "ssh://git@gitea.torbjorn.org:2222/torbjorn/lovdata-chat.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"cypress": "^15.10.0"
}
}

View File

@@ -159,6 +159,24 @@ async def make_http_request(method: str, url: str, **kwargs) -> httpx.Response:
return await client.request(method, url, **kwargs) return await client.request(method, url, **kwargs)
@asynccontextmanager
async def stream_http_request(method: str, url: str, **kwargs):
"""Stream an HTTP response using a dedicated client with no read timeout.
Yields an httpx.Response whose body has NOT been read -- caller must
iterate over ``response.aiter_bytes()`` / ``aiter_lines()`` etc.
A separate AsyncClient is used (not the pool) because httpx's
``stream()`` keeps the connection checked-out for the lifetime of the
context manager, and SSE streams are effectively infinite. Using a
short-lived client avoids starving the pool.
"""
timeout = httpx.Timeout(connect=10.0, read=None, write=10.0, pool=5.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
async with client.stream(method, url, **kwargs) as response:
yield response
async def get_connection_pool_stats() -> Dict[str, Any]: async def get_connection_pool_stats() -> Dict[str, Any]:
"""Get connection pool statistics.""" """Get connection pool statistics."""
return await _http_pool.get_pool_stats() return await _http_pool.get_pool_stats()

View File

@@ -1,13 +1,13 @@
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 RedirectResponse, StreamingResponse
import httpx import httpx
from session_manager import session_manager from session_manager import session_manager
from http_pool import make_http_request from http_pool import make_http_request, stream_http_request
from logging_config import ( from logging_config import (
RequestContext, RequestContext,
log_request, log_request,
@@ -25,276 +25,55 @@ 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}")
async def enter_session(request: Request, session_id: str, path: str = ""):
"""Entry point: set session cookie and redirect to root."""
session = await session_manager.get_session(session_id)
if not session or session.status != "running":
raise HTTPException(
status_code=404, detail="Session not found or not running"
)
resp = RedirectResponse(url="/", status_code=302)
resp.set_cookie(
key="lovdata_session",
value=session_id,
httponly=True,
samesite="lax",
max_age=86400,
)
return resp
def _is_sse_request(request: Request, path: str) -> bool:
"""Detect SSE requests by Accept header or path convention."""
accept = request.headers.get("accept", "")
if "text/event-stream" in accept:
return True
# OpenCode uses /global/event and /event paths for SSE
if path == "event" or path.endswith("/event"):
return True
return False
@router.api_route("/{path:path}", methods=ALL_METHODS)
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) session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"global/{path}") return await _proxy_to_container(request, session_id, path)
@router.api_route("/assets/{path:path}", methods=ALL_METHODS) async def _proxy_to_container(request: Request, session_id: str, path: str):
async def proxy_assets_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"assets/{path}")
@router.api_route("/provider/{path:path}", methods=ALL_METHODS)
async def proxy_provider_path_to_session(request: Request, path: str):
session_id = get_session_from_cookie(request)
return await proxy_to_session(request, session_id, f"provider/{path}")
@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)
@router.api_route("/session/{session_id}/{path:path}", methods=ALL_METHODS)
async def proxy_to_session(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"
) )
@@ -319,6 +98,11 @@ async def proxy_to_session(request: Request, session_id: str, path: str):
headers["X-Session-Token"] = session.auth_token headers["X-Session-Token"] = session.auth_token
headers["X-Session-ID"] = session.session_id headers["X-Session-ID"] = session.session_id
# --- SSE streaming path ---
if _is_sse_request(request, path):
return await _proxy_sse(request, session_id, path, url, headers, body, start_time)
# --- 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
@@ -334,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",
@@ -385,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"
@@ -406,22 +151,66 @@ 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,
detail=f"Failed to connect to session container: {str(e)}", detail=f"Failed to connect to session container: {str(e)}",
) )
async def _proxy_sse(
request: Request,
session_id: str,
path: str,
url: str,
headers: dict,
body: bytes,
start_time: float,
):
"""Proxy an SSE event stream without buffering."""
log_session_operation(
session_id, "proxy_sse_stream", method=request.method, path=path
)
async def event_generator():
try:
async with stream_http_request(
method=request.method,
url=url,
headers=headers,
content=body,
) as upstream:
async for chunk in upstream.aiter_bytes():
yield chunk
except httpx.RequestError as e:
log_security_event(
"proxy_sse_error", "error",
session_id=session_id, method=request.method,
path=path, error=str(e),
)
finally:
duration_ms = (time.time() - start_time) * 1000
log_request(
request.method, f"/{path}", 200, duration_ms,
session_id=session_id, operation="proxy_sse_complete",
)
resp = StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
resp.set_cookie(
key="lovdata_session",
value=session_id,
httponly=True,
samesite="lax",
max_age=86400,
)
return resp