ソースを参照

fix(server): SPA history fallback — serve index.html for client-side routes

A hard navigation/refresh/direct-URL to a React Router route (/login, /settings,
…) reached the static mount as a 404. Add a set_error_handler that serves
index.html for non-API/non-meta/non-asset GET 404s. Bump VERSION -> 0.1.2.
Fszontagh 1 ヶ月 前
コミット
ede4cef690
4 ファイル変更58 行追加2 行削除
  1. 1 1
      VERSION
  2. 25 0
      src/server.cpp
  3. 8 1
      tests/api_fixture.hpp
  4. 24 0
      tests/test_api_integration.cpp

+ 1 - 1
VERSION

@@ -1 +1 @@
-0.1.1
+0.1.2

+ 25 - 0
src/server.cpp

@@ -2,6 +2,8 @@
 #include "errors.hpp"
 #include "json_http.hpp"
 #include <chrono>
+#include <fstream>
+#include <sstream>
 
 namespace svapi {
 
@@ -36,6 +38,29 @@ void ApiServer::registerRoutes() {
         catch (const std::exception& e) { sendJson(res, 500, errorBody("internal", e.what())); }
     });
 
+    // SPA history fallback: a hard navigation / refresh / direct URL to a client-side
+    // route (e.g. /login, /settings) reaches the server as a 404 (no such static file).
+    // Serve index.html so React Router can handle it. Excludes API/meta routes and asset
+    // requests (paths with a file extension), which should keep their real 404.
+    svr_.set_error_handler([this](const httplib::Request& req, httplib::Response& res) {
+        if (res.status == 404 && req.method == "GET" && !d_.webuiDir.empty()
+            && req.path.rfind("/api/", 0) != 0
+            && req.path.rfind("/ui/", 0) != 0
+            && req.path.rfind("/docs", 0) != 0
+            && req.path != "/openapi.json" && req.path != "/llms.txt"
+            && req.path != "/healthz" && req.path != "/readyz"
+            && req.path.find('.') == std::string::npos) {
+            std::ifstream f(d_.webuiDir + "/index.html", std::ios::binary);
+            if (f) {
+                std::stringstream ss; ss << f.rdbuf();
+                res.set_content(ss.str(), "text/html");
+                res.status = 200;
+                return httplib::Server::HandlerResponse::Handled;
+            }
+        }
+        return httplib::Server::HandlerResponse::Unhandled;
+    });
+
     // Authenticate /api/* (authorization is per-handler).
     svr_.set_pre_routing_handler([this](const httplib::Request& req, httplib::Response& res) {
         if (req.method != "OPTIONS" && req.path.rfind("/api/", 0) == 0) {

+ 8 - 1
tests/api_fixture.hpp

@@ -4,6 +4,8 @@
 #include <gtest/gtest.h>
 #include <httplib.h>
 #include <chrono>
+#include <filesystem>
+#include <fstream>
 #include <memory>
 #include <thread>
 #include <vector>
@@ -28,6 +30,7 @@ protected:
     int mockPort_ = 0, mockDim_ = 4;
     std::string settingsColl_ = "svapitest_api_settings";
     std::string keysColl_     = "svapitest_api_keys";
+    std::string webuiDir_     = "/tmp/svapi_webui_test";   // sentinel SPA root for fallback tests
 
     void SetUp() override {
         db_ = connectOrNull();
@@ -62,7 +65,11 @@ protected:
         s.openaiApiKey = "test";
         settings_->save(s);
 
-        ServerDeps deps{*db_, *settings_, *keys_, *registry_, *sessions_, "", apiDocsDir()};
+        std::filesystem::create_directories(webuiDir_);
+        std::ofstream(webuiDir_ + "/index.html")
+            << "<!doctype html><html><head><title>SVAPI_SPA_OK</title></head><body><div id=\"root\"></div></body></html>";
+
+        ServerDeps deps{*db_, *settings_, *keys_, *registry_, *sessions_, webuiDir_, apiDocsDir()};
         server_ = std::make_unique<ApiServer>(std::move(deps));
         server_->registerRoutes();
         port_ = server_->bindToAnyPort("127.0.0.1");

+ 24 - 0
tests/test_api_integration.cpp

@@ -334,3 +334,27 @@ TEST_F(ApiFixture, SettingsPutChangesField) {
     admin().Put("/api/v1/settings",
         nlohmann::json{{"default_embedding_model", "text-embedding-3-small"}}.dump(), "application/json");
 }
+
+TEST_F(ApiFixture, SpaFallbackServesIndexForClientRoutes) {
+    for (const char* route : {"/login", "/settings", "/collections", "/documents"}) {
+        auto r = noAuth().Get(route);
+        ASSERT_TRUE(r) << route;
+        EXPECT_EQ(r->status, 200) << route;
+        EXPECT_NE(r->body.find("SVAPI_SPA_OK"), std::string::npos) << route;
+    }
+}
+
+TEST_F(ApiFixture, SpaFallbackDoesNotClobberApiMetaOrAssets) {
+    // /api stays a real 401 JSON (not the SPA html)
+    auto a = noAuth().Get("/api/v1/projects");
+    ASSERT_TRUE(a); EXPECT_EQ(a->status, 401);
+    EXPECT_EQ(a->body.find("SVAPI_SPA_OK"), std::string::npos);
+    // a missing asset (path with an extension) keeps its 404
+    auto j = noAuth().Get("/assets/missing.js");
+    ASSERT_TRUE(j); EXPECT_EQ(j->status, 404);
+    EXPECT_EQ(j->body.find("SVAPI_SPA_OK"), std::string::npos);
+    // meta is still the real thing
+    auto o = noAuth().Get("/openapi.json");
+    ASSERT_TRUE(o); EXPECT_EQ(o->status, 200);
+    EXPECT_EQ(o->body.find("SVAPI_SPA_OK"), std::string::npos);
+}