mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-26 12:15:48 +00:00
Compare commits
3 Commits
csv_render
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa14ee3059 | ||
|
|
ce69c91ce5 | ||
|
|
10f731c2f4 |
@@ -0,0 +1,32 @@
|
||||
"""add is_public to build_session
|
||||
|
||||
Revision ID: c7f2e1b4a9d3
|
||||
Revises: 19c0ccb01687
|
||||
Create Date: 2026-02-17 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c7f2e1b4a9d3"
|
||||
down_revision = "19c0ccb01687"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"build_session",
|
||||
sa.Column(
|
||||
"is_public",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("build_session", "is_public")
|
||||
@@ -4712,6 +4712,9 @@ class BuildSession(Base):
|
||||
demo_data_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default=text("true")
|
||||
)
|
||||
is_public: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped[User | None] = relationship("User", foreign_keys=[user_id])
|
||||
|
||||
@@ -64,7 +64,7 @@ from onyx.server.documents.connector import router as connector_router
|
||||
from onyx.server.documents.credential import router as credential_router
|
||||
from onyx.server.documents.document import router as document_router
|
||||
from onyx.server.documents.standard_oauth import router as standard_oauth_router
|
||||
from onyx.server.features.build.api.api import nextjs_assets_router
|
||||
from onyx.server.features.build.api.api import public_build_router
|
||||
from onyx.server.features.build.api.api import router as build_router
|
||||
from onyx.server.features.default_assistant.api import (
|
||||
router as default_assistant_router,
|
||||
@@ -378,8 +378,8 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
|
||||
include_router_with_global_prefix_prepended(application, admin_input_prompt_router)
|
||||
include_router_with_global_prefix_prepended(application, cc_pair_router)
|
||||
include_router_with_global_prefix_prepended(application, projects_router)
|
||||
include_router_with_global_prefix_prepended(application, public_build_router)
|
||||
include_router_with_global_prefix_prepended(application, build_router)
|
||||
include_router_with_global_prefix_prepended(application, nextjs_assets_router)
|
||||
include_router_with_global_prefix_prepended(application, document_set_router)
|
||||
include_router_with_global_prefix_prepended(application, hierarchy_router)
|
||||
include_router_with_global_prefix_prepended(application, search_settings_router)
|
||||
|
||||
@@ -59,6 +59,9 @@ PUBLIC_ENDPOINT_SPECS = [
|
||||
# anonymous user on cloud
|
||||
("/tenants/anonymous-user", {"POST"}),
|
||||
("/metrics", {"GET"}), # added by prometheus_fastapi_instrumentator
|
||||
# craft webapp proxy — public when session.is_public=True (access enforced in handler)
|
||||
("/build/sessions/{session_id}/webapp", {"GET"}),
|
||||
("/build/sessions/{session_id}/webapp/{path:path}", {"GET"}),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.auth.users import optional_user
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.db.connector_credential_pair import get_connector_credential_pairs_for_user
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
@@ -217,12 +218,15 @@ def get_build_connectors(
|
||||
return BuildConnectorListResponse(connectors=connectors)
|
||||
|
||||
|
||||
# Headers to skip when proxying (hop-by-hop headers)
|
||||
# Headers to skip when proxying.
|
||||
# Hop-by-hop headers must not be forwarded, and set-cookie is stripped to
|
||||
# prevent LLM-generated apps from setting cookies on the parent Onyx domain.
|
||||
EXCLUDED_HEADERS = {
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"set-cookie",
|
||||
}
|
||||
|
||||
|
||||
@@ -365,71 +369,173 @@ def _proxy_request(
|
||||
raise HTTPException(status_code=502, detail="Bad gateway")
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/webapp", response_model=None)
|
||||
def _check_webapp_access(
|
||||
session_id: UUID, user: User | None, db_session: Session
|
||||
) -> BuildSession:
|
||||
"""Check if user can access a session's webapp.
|
||||
|
||||
- Public sessions: accessible by anyone (no auth required)
|
||||
- Private sessions: only accessible by the session owner
|
||||
|
||||
Returns the session if access is granted, raises HTTPException otherwise.
|
||||
"""
|
||||
session = db_session.get(BuildSession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.is_public:
|
||||
return session
|
||||
# Private session: require authenticated owner
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if session.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return session
|
||||
|
||||
|
||||
def _offline_html_response() -> Response:
|
||||
"""Return a branded Craft HTML page when the sandbox is not reachable.
|
||||
|
||||
Design mirrors the default Craft web template (outputs/web/app/page.tsx):
|
||||
terminal window aesthetic with Minecraft-themed typing animation.
|
||||
"""
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="refresh" content="15" />
|
||||
<title>Craft — Starting up</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
background: linear-gradient(to bottom right, #030712, #111827, #030712);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
border: 2px solid #374151;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
background: #1f2937;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
.btn { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
||||
.btn-red { background: #ef4444; }
|
||||
.btn-yellow { background: #eab308; }
|
||||
.btn-green { background: #22c55e; }
|
||||
|
||||
.title-label {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-right: 36px;
|
||||
}
|
||||
|
||||
.body {
|
||||
background: #111827;
|
||||
padding: 1.5rem;
|
||||
min-height: 200px;
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.prompt { color: #10b981; user-select: none; }
|
||||
|
||||
.tagline {
|
||||
font-size: 0.8125rem;
|
||||
color: #4b5563;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="terminal">
|
||||
<div class="titlebar">
|
||||
<div class="btn btn-red"></div>
|
||||
<div class="btn btn-yellow"></div>
|
||||
<div class="btn btn-green"></div>
|
||||
<span class="title-label">crafting_table</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<span class="prompt">/></span>
|
||||
<span>Sandbox is asleep...</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tagline">Ask the owner to open their Craft session to wake it up.</p>
|
||||
</body>
|
||||
</html>"""
|
||||
return Response(content=html, status_code=503, media_type="text/html")
|
||||
|
||||
|
||||
# Public router for webapp proxy — no authentication required
|
||||
# (access controlled per-session via is_public flag)
|
||||
public_build_router = APIRouter(prefix="/build")
|
||||
|
||||
|
||||
@public_build_router.get("/sessions/{session_id}/webapp", response_model=None)
|
||||
def get_webapp_root(
|
||||
session_id: UUID,
|
||||
request: Request,
|
||||
_: User = Depends(current_user),
|
||||
user: User | None = Depends(optional_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StreamingResponse | Response:
|
||||
"""Proxy the root path of the webapp for a specific session."""
|
||||
return _proxy_request("", request, session_id, db_session)
|
||||
"""Proxy the root path of the webapp for a specific session.
|
||||
|
||||
Accessible without authentication when session.is_public=True.
|
||||
Returns a friendly offline page when the sandbox is not running.
|
||||
"""
|
||||
_check_webapp_access(session_id, user, db_session)
|
||||
try:
|
||||
return _proxy_request("", request, session_id, db_session)
|
||||
except HTTPException as e:
|
||||
if e.status_code in (502, 503, 504):
|
||||
return _offline_html_response()
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/webapp/{path:path}", response_model=None)
|
||||
@public_build_router.get(
|
||||
"/sessions/{session_id}/webapp/{path:path}", response_model=None
|
||||
)
|
||||
def get_webapp_path(
|
||||
session_id: UUID,
|
||||
path: str,
|
||||
request: Request,
|
||||
_: User = Depends(current_user),
|
||||
user: User | None = Depends(optional_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StreamingResponse | Response:
|
||||
"""Proxy any subpath of the webapp (static assets, etc.) for a specific session."""
|
||||
return _proxy_request(path, request, session_id, db_session)
|
||||
"""Proxy any subpath of the webapp for a specific session.
|
||||
|
||||
|
||||
# Separate router for Next.js static assets at /_next/*
|
||||
# This is needed because Next.js apps may reference assets with root-relative paths
|
||||
# that don't get rewritten. The session_id is extracted from the Referer header.
|
||||
nextjs_assets_router = APIRouter()
|
||||
|
||||
|
||||
def _extract_session_from_referer(request: Request) -> UUID | None:
|
||||
"""Extract session_id from the Referer header.
|
||||
|
||||
Expects Referer to contain /api/build/sessions/{session_id}/webapp
|
||||
Accessible without authentication when session.is_public=True.
|
||||
Returns a friendly offline page when the sandbox is not running.
|
||||
"""
|
||||
import re
|
||||
|
||||
referer = request.headers.get("referer", "")
|
||||
match = re.search(r"/api/build/sessions/([a-f0-9-]+)/webapp", referer)
|
||||
if match:
|
||||
try:
|
||||
return UUID(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@nextjs_assets_router.get("/_next/{path:path}", response_model=None)
|
||||
def get_nextjs_assets(
|
||||
path: str,
|
||||
request: Request,
|
||||
_: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StreamingResponse | Response:
|
||||
"""Proxy Next.js static assets requested at root /_next/ path.
|
||||
|
||||
The session_id is extracted from the Referer header since these requests
|
||||
come from within the iframe context.
|
||||
"""
|
||||
session_id = _extract_session_from_referer(request)
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Could not determine session from request context",
|
||||
)
|
||||
return _proxy_request(f"_next/{path}", request, session_id, db_session)
|
||||
_check_webapp_access(session_id, user, db_session)
|
||||
try:
|
||||
return _proxy_request(path, request, session_id, db_session)
|
||||
except HTTPException as e:
|
||||
if e.status_code in (502, 503, 504):
|
||||
return _offline_html_response()
|
||||
raise
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -107,6 +107,7 @@ class SessionResponse(BaseModel):
|
||||
nextjs_port: int | None
|
||||
sandbox: SandboxResponse | None
|
||||
artifacts: list[ArtifactResponse]
|
||||
is_public: bool
|
||||
|
||||
@classmethod
|
||||
def from_model(
|
||||
@@ -129,6 +130,7 @@ class SessionResponse(BaseModel):
|
||||
nextjs_port=session.nextjs_port,
|
||||
sandbox=(SandboxResponse.from_model(sandbox) if sandbox else None),
|
||||
artifacts=[ArtifactResponse.from_model(a) for a in session.artifacts],
|
||||
is_public=session.is_public,
|
||||
)
|
||||
|
||||
|
||||
@@ -159,6 +161,19 @@ class SessionListResponse(BaseModel):
|
||||
sessions: list[SessionResponse]
|
||||
|
||||
|
||||
class SetSessionPublicRequest(BaseModel):
|
||||
"""Request to set the public status of a session."""
|
||||
|
||||
is_public: bool
|
||||
|
||||
|
||||
class SetSessionPublicResponse(BaseModel):
|
||||
"""Response after setting session public status."""
|
||||
|
||||
session_id: str
|
||||
is_public: bool
|
||||
|
||||
|
||||
# ===== Message Models =====
|
||||
class MessageRequest(BaseModel):
|
||||
"""Request to send a message to the CLI agent."""
|
||||
@@ -244,6 +259,7 @@ class WebappInfo(BaseModel):
|
||||
webapp_url: str | None # URL to access the webapp (e.g., http://localhost:3015)
|
||||
status: str # Sandbox status (running, terminated, etc.)
|
||||
ready: bool # Whether the NextJS dev server is actually responding
|
||||
is_public: bool # Whether the webapp is publicly accessible without auth
|
||||
|
||||
|
||||
# ===== File Upload Models =====
|
||||
|
||||
@@ -30,6 +30,8 @@ from onyx.server.features.build.api.models import SessionListResponse
|
||||
from onyx.server.features.build.api.models import SessionNameGenerateResponse
|
||||
from onyx.server.features.build.api.models import SessionResponse
|
||||
from onyx.server.features.build.api.models import SessionUpdateRequest
|
||||
from onyx.server.features.build.api.models import SetSessionPublicRequest
|
||||
from onyx.server.features.build.api.models import SetSessionPublicResponse
|
||||
from onyx.server.features.build.api.models import SuggestionBubble
|
||||
from onyx.server.features.build.api.models import SuggestionTheme
|
||||
from onyx.server.features.build.api.models import UploadResponse
|
||||
@@ -38,6 +40,7 @@ from onyx.server.features.build.configs import SANDBOX_BACKEND
|
||||
from onyx.server.features.build.configs import SandboxBackend
|
||||
from onyx.server.features.build.db.build_session import allocate_nextjs_port
|
||||
from onyx.server.features.build.db.build_session import get_build_session
|
||||
from onyx.server.features.build.db.build_session import set_build_session_public_status
|
||||
from onyx.server.features.build.db.sandbox import get_latest_snapshot_for_session
|
||||
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
|
||||
from onyx.server.features.build.db.sandbox import update_sandbox_heartbeat
|
||||
@@ -294,6 +297,29 @@ def update_session_name(
|
||||
return SessionResponse.from_model(session, sandbox)
|
||||
|
||||
|
||||
@router.patch("/{session_id}/public")
|
||||
def set_session_public(
|
||||
session_id: UUID,
|
||||
request: SetSessionPublicRequest,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> SetSessionPublicResponse:
|
||||
"""Set whether a build session webapp is publicly accessible.
|
||||
|
||||
When is_public=True, anyone with the session URL can view the webapp
|
||||
without authentication.
|
||||
"""
|
||||
updated = set_build_session_public_status(
|
||||
session_id, user.id, request.is_public, db_session
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return SetSessionPublicResponse(
|
||||
session_id=str(session_id),
|
||||
is_public=updated.is_public,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{session_id}", response_model=None)
|
||||
def delete_session(
|
||||
session_id: UUID,
|
||||
|
||||
@@ -159,6 +159,26 @@ def update_session_status(
|
||||
logger.info(f"Updated build session {session_id} status to {status}")
|
||||
|
||||
|
||||
def set_build_session_public_status(
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
is_public: bool,
|
||||
db_session: Session,
|
||||
) -> BuildSession | None:
|
||||
"""Set the public/private status of a build session.
|
||||
|
||||
Only the session owner can change this setting.
|
||||
Returns the updated session, or None if not found/unauthorized.
|
||||
"""
|
||||
session = get_build_session(session_id, user_id, db_session)
|
||||
if not session:
|
||||
return None
|
||||
session.is_public = is_public
|
||||
db_session.commit()
|
||||
logger.info(f"Set build session {session_id} is_public={is_public}")
|
||||
return session
|
||||
|
||||
|
||||
def delete_build_session__no_commit(
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
|
||||
@@ -474,6 +474,23 @@ class SandboxManager(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
def ensure_nextjs_running(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
nextjs_port: int,
|
||||
) -> None:
|
||||
"""Ensure the Next.js server is running for a session.
|
||||
|
||||
Default is a no-op — only meaningful for local backends that manage
|
||||
process lifecycles directly (e.g., LocalSandboxManager).
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID
|
||||
nextjs_port: The port the Next.js server should be listening on
|
||||
"""
|
||||
|
||||
|
||||
# Singleton instance cache for the factory
|
||||
_sandbox_manager_instance: SandboxManager | None = None
|
||||
|
||||
@@ -89,9 +89,17 @@ class LocalSandboxManager(SandboxManager):
|
||||
self._acp_clients: dict[tuple[UUID, UUID], ACPAgentClient] = {}
|
||||
|
||||
# Track Next.js processes - keyed by (sandbox_id, session_id) tuple
|
||||
# Used for clean shutdown when sessions are deleted
|
||||
# Used for clean shutdown when sessions are deleted.
|
||||
# Mutated from background threads; all access must hold _nextjs_lock.
|
||||
self._nextjs_processes: dict[tuple[UUID, UUID], subprocess.Popen[bytes]] = {}
|
||||
|
||||
# Track sessions currently being (re)started - prevents concurrent restarts.
|
||||
# Mutated from background threads; all access must hold _nextjs_lock.
|
||||
self._nextjs_starting: set[tuple[UUID, UUID]] = set()
|
||||
|
||||
# Lock guarding both _nextjs_processes and _nextjs_starting
|
||||
self._nextjs_lock = threading.Lock()
|
||||
|
||||
# Validate templates exist (raises RuntimeError if missing)
|
||||
self._validate_templates()
|
||||
|
||||
@@ -326,16 +334,18 @@ class LocalSandboxManager(SandboxManager):
|
||||
RuntimeError: If termination fails
|
||||
"""
|
||||
# Stop all Next.js processes for this sandbox (keyed by (sandbox_id, session_id))
|
||||
processes_to_stop = [
|
||||
(key, process)
|
||||
for key, process in self._nextjs_processes.items()
|
||||
if key[0] == sandbox_id
|
||||
]
|
||||
with self._nextjs_lock:
|
||||
processes_to_stop = [
|
||||
(key, process)
|
||||
for key, process in self._nextjs_processes.items()
|
||||
if key[0] == sandbox_id
|
||||
]
|
||||
for key, process in processes_to_stop:
|
||||
session_id = key[1]
|
||||
try:
|
||||
self._stop_nextjs_process(process, session_id)
|
||||
del self._nextjs_processes[key]
|
||||
with self._nextjs_lock:
|
||||
self._nextjs_processes.pop(key, None)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to stop Next.js for sandbox {sandbox_id}, "
|
||||
@@ -516,7 +526,8 @@ class LocalSandboxManager(SandboxManager):
|
||||
web_dir, nextjs_port
|
||||
)
|
||||
# Store process for clean shutdown on session delete
|
||||
self._nextjs_processes[(sandbox_id, session_id)] = nextjs_process
|
||||
with self._nextjs_lock:
|
||||
self._nextjs_processes[(sandbox_id, session_id)] = nextjs_process
|
||||
logger.info("Next.js server started successfully")
|
||||
|
||||
# Setup venv and AGENTS.md
|
||||
@@ -575,7 +586,8 @@ class LocalSandboxManager(SandboxManager):
|
||||
"""
|
||||
# Stop Next.js dev server - try stored process first, then fallback to port lookup
|
||||
process_key = (sandbox_id, session_id)
|
||||
nextjs_process = self._nextjs_processes.pop(process_key, None)
|
||||
with self._nextjs_lock:
|
||||
nextjs_process = self._nextjs_processes.pop(process_key, None)
|
||||
if nextjs_process is not None:
|
||||
self._stop_nextjs_process(nextjs_process, session_id)
|
||||
elif nextjs_port is not None:
|
||||
@@ -766,6 +778,87 @@ class LocalSandboxManager(SandboxManager):
|
||||
outputs_path = session_path / "outputs"
|
||||
return outputs_path.exists()
|
||||
|
||||
def ensure_nextjs_running(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
session_id: UUID,
|
||||
nextjs_port: int,
|
||||
) -> None:
|
||||
"""Start Next.js server for a session if not already running.
|
||||
|
||||
Called when the server is detected as unreachable (e.g., after API server restart).
|
||||
Returns immediately — the actual startup runs in a background daemon thread.
|
||||
A per-session guard prevents concurrent restarts from racing.
|
||||
|
||||
Args:
|
||||
sandbox_id: The sandbox ID
|
||||
session_id: The session ID
|
||||
nextjs_port: The port number for the Next.js server
|
||||
"""
|
||||
process_key = (sandbox_id, session_id)
|
||||
|
||||
with self._nextjs_lock:
|
||||
# Already have a live tracked process
|
||||
existing = self._nextjs_processes.get(process_key)
|
||||
if existing is not None and existing.poll() is None:
|
||||
return
|
||||
|
||||
# Already being started by another thread
|
||||
if process_key in self._nextjs_starting:
|
||||
return
|
||||
|
||||
# Check if the port is already responding (orphan process that survived restart)
|
||||
try:
|
||||
import httpx
|
||||
|
||||
with httpx.Client(timeout=1.0) as client:
|
||||
client.get(f"http://localhost:{nextjs_port}")
|
||||
logger.info(
|
||||
f"Port {nextjs_port} already alive for session {session_id} "
|
||||
"(orphan process) — skipping restart"
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass # Port is dead; proceed to restart
|
||||
|
||||
self._nextjs_starting.add(process_key)
|
||||
|
||||
logger.info(
|
||||
f"Scheduling background Next.js restart for session {session_id} "
|
||||
f"on port {nextjs_port}"
|
||||
)
|
||||
|
||||
def _start_in_background() -> None:
|
||||
try:
|
||||
sandbox_path = self._get_sandbox_path(sandbox_id)
|
||||
web_dir = self._directory_manager.get_web_path(
|
||||
sandbox_path, str(session_id)
|
||||
)
|
||||
if not web_dir.exists():
|
||||
logger.warning(
|
||||
f"Web dir missing for session {session_id}: {web_dir} — "
|
||||
"cannot restart Next.js"
|
||||
)
|
||||
return
|
||||
process = self._process_manager.start_nextjs_server(
|
||||
web_dir, nextjs_port
|
||||
)
|
||||
with self._nextjs_lock:
|
||||
self._nextjs_processes[process_key] = process
|
||||
logger.info(
|
||||
f"Auto-restarted Next.js for session {session_id} "
|
||||
f"on port {nextjs_port}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to auto-restart Next.js for session {session_id}: {e}"
|
||||
)
|
||||
finally:
|
||||
with self._nextjs_lock:
|
||||
self._nextjs_starting.discard(process_key)
|
||||
|
||||
threading.Thread(target=_start_in_background, daemon=True).start()
|
||||
|
||||
def restore_snapshot(
|
||||
self,
|
||||
sandbox_id: UUID,
|
||||
|
||||
@@ -1765,6 +1765,7 @@ class SessionManager:
|
||||
"webapp_url": None,
|
||||
"status": "no_sandbox",
|
||||
"ready": False,
|
||||
"is_public": session.is_public,
|
||||
}
|
||||
|
||||
# Return the proxy URL - the proxy handles routing to the correct sandbox
|
||||
@@ -1777,11 +1778,21 @@ class SessionManager:
|
||||
# Quick health check: can the API server reach the NextJS dev server?
|
||||
ready = self._check_nextjs_ready(sandbox.id, session.nextjs_port)
|
||||
|
||||
# If not ready, ask the sandbox manager to ensure Next.js is running.
|
||||
# For the local backend this triggers a background restart so that the
|
||||
# frontend poll loop eventually sees ready=True without the user having
|
||||
# to manually recreate the session.
|
||||
if not ready:
|
||||
self._sandbox_manager.ensure_nextjs_running(
|
||||
sandbox.id, session_id, session.nextjs_port
|
||||
)
|
||||
|
||||
return {
|
||||
"has_webapp": session.nextjs_port is not None,
|
||||
"webapp_url": webapp_url,
|
||||
"status": sandbox.status.value,
|
||||
"ready": ready,
|
||||
"is_public": session.is_public,
|
||||
}
|
||||
|
||||
def _check_nextjs_ready(self, sandbox_id: UUID, port: int) -> bool:
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@opal/icons";
|
||||
import { IconProps } from "@opal/types";
|
||||
import CraftingLoader from "@/app/craft/components/CraftingLoader";
|
||||
import ShareButton from "@/app/craft/components/ShareButton";
|
||||
|
||||
// Output panel sub-components
|
||||
import UrlBar from "@/app/craft/components/output-panel/UrlBar";
|
||||
@@ -615,6 +616,20 @@ const BuildOutputPanel = memo(({ onClose, isOpen }: BuildOutputPanelProps) => {
|
||||
onDownload={isMarkdownPreview ? handleDocxDownload : undefined}
|
||||
isDownloading={isExportingDocx}
|
||||
onRefresh={handleRefresh}
|
||||
shareButton={
|
||||
!isFilePreviewActive &&
|
||||
activeOutputTab === "preview" &&
|
||||
session?.id &&
|
||||
displayUrl?.startsWith("http") ? (
|
||||
<ShareButton
|
||||
key={session.id}
|
||||
sessionId={session.id}
|
||||
webappUrl={displayUrl}
|
||||
isPublic={webappInfo?.is_public ?? false}
|
||||
onPublicChange={mutate}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
|
||||
148
web/src/app/craft/components/ShareButton.tsx
Normal file
148
web/src/app/craft/components/ShareButton.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import { SvgLink, SvgCopy, SvgCheck, SvgX } from "@opal/icons";
|
||||
import { setSessionPublic } from "@/app/craft/services/apiServices";
|
||||
|
||||
interface ShareButtonProps {
|
||||
sessionId: string;
|
||||
webappUrl: string;
|
||||
isPublic: boolean;
|
||||
onPublicChange?: () => void;
|
||||
}
|
||||
|
||||
export default function ShareButton({
|
||||
sessionId,
|
||||
webappUrl,
|
||||
isPublic: initialIsPublic,
|
||||
onPublicChange,
|
||||
}: ShareButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isPublic, setIsPublic] = useState(initialIsPublic);
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "error">(
|
||||
"idle"
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Build the full share URL from the webapp URL path
|
||||
const shareUrl = webappUrl.startsWith("http")
|
||||
? webappUrl
|
||||
: `${window.location.origin}${webappUrl}`;
|
||||
|
||||
// Close popover on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggle = async (newValue: boolean) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await setSessionPublic(sessionId, newValue);
|
||||
setIsPublic(newValue);
|
||||
onPublicChange?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to update sharing:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
let success = false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
success = true;
|
||||
} catch {
|
||||
// Clipboard API unavailable (HTTP context or permission denied) — try execCommand
|
||||
try {
|
||||
const el = document.createElement("textarea");
|
||||
el.value = shareUrl;
|
||||
el.style.cssText = "position:fixed;opacity:0";
|
||||
document.body.appendChild(el);
|
||||
el.focus();
|
||||
el.select();
|
||||
success = document.execCommand("copy");
|
||||
document.body.removeChild(el);
|
||||
} catch {
|
||||
// Both methods failed
|
||||
}
|
||||
}
|
||||
setCopyState(success ? "copied" : "error");
|
||||
setTimeout(() => setCopyState("idle"), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" ref={popoverRef}>
|
||||
<Button
|
||||
action
|
||||
primary={isPublic}
|
||||
tertiary={!isPublic}
|
||||
leftIcon={SvgLink}
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
aria-label="Share webapp"
|
||||
>
|
||||
{isPublic ? "Shared" : "Share"}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full right-0 mt-2 z-50 w-80 rounded-12 border border-border-01 bg-background-neutral-00 shadow-lg p-4 flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Text mainUiAction text04>
|
||||
Share app
|
||||
</Text>
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Text secondaryBody text03>
|
||||
{isPublic
|
||||
? "Anyone with the link can view this app."
|
||||
: "Enable sharing to let anyone view this app with a link."}
|
||||
</Text>
|
||||
|
||||
{/* URL row - only shown when public */}
|
||||
{isPublic && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-08 bg-background-tint-02">
|
||||
<Text secondaryBody text03 className="flex-1 truncate">
|
||||
{shareUrl}
|
||||
</Text>
|
||||
<Button
|
||||
action
|
||||
tertiary
|
||||
size="md"
|
||||
leftIcon={
|
||||
copyState === "copied"
|
||||
? SvgCheck
|
||||
: copyState === "error"
|
||||
? SvgX
|
||||
: SvgCopy
|
||||
}
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy link"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export interface UrlBarProps {
|
||||
isDownloading?: boolean;
|
||||
/** Optional refresh callback — shows a refresh icon at the right edge of the URL pill */
|
||||
onRefresh?: () => void;
|
||||
/** Optional share button node — shown on the far right of the URL bar */
|
||||
shareButton?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +62,7 @@ export default function UrlBar({
|
||||
onDownload,
|
||||
isDownloading = false,
|
||||
onRefresh,
|
||||
shareButton,
|
||||
}: UrlBarProps) {
|
||||
const handleOpenInNewTab = () => {
|
||||
if (previewUrl) {
|
||||
@@ -152,6 +155,8 @@ export default function UrlBar({
|
||||
{isDownloading ? "Exporting..." : "Export to .docx"}
|
||||
</Button>
|
||||
)}
|
||||
{/* Share button — shown on the far right when a webapp is active */}
|
||||
{shareButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -198,6 +198,23 @@ export async function updateSessionName(
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSessionPublic(
|
||||
sessionId: string,
|
||||
isPublic: boolean
|
||||
): Promise<{ session_id: string; is_public: boolean }> {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}/public`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_public: isPublic }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to update session visibility: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -165,6 +165,7 @@ export interface ApiSessionResponse {
|
||||
last_activity_at: string;
|
||||
sandbox: ApiSandboxResponse | null;
|
||||
artifacts: ApiArtifactResponse[];
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
export interface ApiDetailedSessionResponse extends ApiSessionResponse {
|
||||
@@ -196,6 +197,7 @@ export interface ApiWebappInfoResponse {
|
||||
webapp_url: string | null;
|
||||
status: string;
|
||||
ready: boolean;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
export interface FileSystemEntry {
|
||||
|
||||
Reference in New Issue
Block a user