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:
Benjiyaya
2026-08-25 20:12:08 +08:00
parent 0e0ee540e0
commit cfc13f4aee
5 changed files with 131 additions and 10 deletions
-3
View File
@@ -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.
+52 -4
View File
@@ -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
+75
View File
@@ -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 -3
View File
@@ -1,6 +1,4 @@
import { error } from '@sveltejs/kit';
export const prerender = true;
export const prerender = false;
export const ssr = false;
export function load() {
+3
View File
@@ -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 }) => {