Stop offering a Windows zip while Create Project 404s.
The packaged FastAPI static mount returned JSON 404 for /project/{id}. Serve the SvelteKit fallback page for client routes, and point README at source until a zip is trustworthy.
This commit is contained in:
@@ -13,8 +13,6 @@ Calliope is a local-first story-to-video studio. You type a story idea; Calliope
|
||||
|
||||
## Install — from source (npm + Python)
|
||||
|
||||
For development, or if you prefer running the web UI in a browser instead of the Electron shell.
|
||||
|
||||
**Prerequisites**
|
||||
|
||||
- Python 3.11+
|
||||
@@ -177,4 +175,3 @@ example_ComfyUI_workflows/ ready-to-import API-format workflow JSONs
|
||||
docs/wiki/ design notes (wiki source): ComfyUI HTTP vs MCP, multi-ref workflows
|
||||
```
|
||||
|
||||
The Windows desktop app is built from this repo but not shipped in it — download it from the [Releases](../../releases) page.
|
||||
|
||||
@@ -8,6 +8,9 @@ from pathlib import Path
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.responses import Response
|
||||
from starlette.types import Scope
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db, rebase_stale_asset_paths
|
||||
@@ -31,6 +34,51 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger("calliope")
|
||||
|
||||
# Hashed SPA assets can be cached; HTML and unknown client routes must not, or
|
||||
# Electron will keep showing a previous install's index.html from 127.0.0.1.
|
||||
_IMMUTABLE_SUFFIXES = {
|
||||
".js",
|
||||
".css",
|
||||
".map",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".svg",
|
||||
".webp",
|
||||
".gif",
|
||||
".ico",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".json",
|
||||
}
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
"""Serve SvelteKit's fallback index.html for client routes like /project/12.
|
||||
|
||||
Starlette StaticFiles(html=True) only maps directories to index.html, not
|
||||
unknown paths. Without this, the packaged app returns FastAPI JSON
|
||||
``{"detail":"Not Found"}`` after Create Project (a full navigation to
|
||||
``/project/{id}``).
|
||||
"""
|
||||
|
||||
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||
fallback = False
|
||||
try:
|
||||
response = await super().get_response(path, scope)
|
||||
except StarletteHTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
suffix = Path(path).suffix.lower()
|
||||
if suffix in _IMMUTABLE_SUFFIXES:
|
||||
raise
|
||||
fallback = True
|
||||
response = await super().get_response("index.html", scope)
|
||||
if fallback or path.endswith(".html") or path in {"", ".", "index.html"}:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -61,7 +109,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Calliope shutting down")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
def create_app(static_dir: Path | None = None) -> FastAPI:
|
||||
app = FastAPI(title="Calliope", version="1.2.1", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
@@ -97,9 +145,9 @@ def create_app() -> FastAPI:
|
||||
async def api_not_found(full_path: str) -> None:
|
||||
raise HTTPException(status_code=404, detail=f"API route not found: /api/{full_path}")
|
||||
|
||||
static_dir = Path(__file__).resolve().parent / "static"
|
||||
if static_dir.exists():
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
resolved_static = static_dir if static_dir is not None else Path(__file__).resolve().parent / "static"
|
||||
if resolved_static.exists():
|
||||
app.mount("/", SPAStaticFiles(directory=resolved_static, html=True), name="static")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from calliope.db import migrate_db
|
||||
from calliope.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spa_client(monkeypatch, tmp_path: Path):
|
||||
import calliope.config as config_module
|
||||
|
||||
static = tmp_path / "static"
|
||||
static.mkdir()
|
||||
(static / "index.html").write_text("<!doctype html><title>SPA</title>ok", encoding="utf-8")
|
||||
(static / "hello.txt").write_text("hi", encoding="utf-8")
|
||||
|
||||
s = config_module.settings
|
||||
prev = {
|
||||
"data_dir": s.data_dir,
|
||||
"assets_dir": s.assets_dir,
|
||||
"dry_run": s.dry_run,
|
||||
}
|
||||
s.data_dir = tmp_path / "data"
|
||||
s.assets_dir = s.data_dir / "assets"
|
||||
s.dry_run = True
|
||||
monkeypatch.setattr(config_module.Settings, "save_config_file", lambda self: None)
|
||||
asyncio.run(migrate_db(s.db_path))
|
||||
app = create_app(static_dir=static)
|
||||
try:
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
finally:
|
||||
for k, v in prev.items():
|
||||
setattr(s, k, v)
|
||||
|
||||
|
||||
def test_client_route_serves_index_html(spa_client):
|
||||
r = spa_client.get("/project/12")
|
||||
assert r.status_code == 200
|
||||
assert "SPA" in r.text
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
|
||||
|
||||
def test_projects_path_serves_index_html(spa_client):
|
||||
r = spa_client.get("/projects")
|
||||
assert r.status_code == 200
|
||||
assert "SPA" in r.text
|
||||
|
||||
|
||||
def test_real_static_file_still_served(spa_client):
|
||||
r = spa_client.get("/hello.txt")
|
||||
assert r.status_code == 200
|
||||
assert r.text == "hi"
|
||||
|
||||
|
||||
def test_missing_js_stays_404(spa_client):
|
||||
r = spa_client.get("/_app/missing.js")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_api_still_json_404(spa_client):
|
||||
r = spa_client.get("/api/does-not-exist")
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"].startswith("API route not found")
|
||||
|
||||
|
||||
def test_health_untouched(spa_client):
|
||||
r = spa_client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
@@ -1,6 +1,4 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const prerender = true;
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
|
||||
export function load() {
|
||||
|
||||
@@ -10,6 +10,9 @@ const config = {
|
||||
}),
|
||||
paths: {
|
||||
base: '',
|
||||
// Absolute /_app/... URLs so a document load of /project/12 still
|
||||
// finds the bundle. adapter-static otherwise emits ./_app on prerendered pages.
|
||||
relative: false,
|
||||
},
|
||||
prerender: {
|
||||
handleHttpError: ({ path, message }) => {
|
||||
|
||||
Reference in New Issue
Block a user