BareGit

Serve embedded static assets through App

Add an App subclass of mw::HTTPServer and generate embedded assets with
CMake. Start the server on localhost port 8080 from main and cover static
responses, HEAD requests, and unknown paths with HTTP integration tests.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Sep 19 08:47:41 2026 -0700
Commit: 041a4fbc40dbbbd676992f9f3b5250fefc107c78

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4139c50..743491f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -5,6 +5,7 @@ project(status_tracker LANGUAGES CXX)
 include(FetchContent)
 set(LIBMW_BUILD_SQLITE ON CACHE BOOL "Build libmw SQLite support")
 set(LIBMW_BUILD_URL ON)
+set(LIBMW_BUILD_HTTP_SERVER ON)
 FetchContent_Declare(libmw
     GIT_REPOSITORY https://github.com/MetroWind/libmw.git
     GIT_TAG HEAD
@@ -25,7 +26,15 @@ if(NOT TARGET SQLite3::SQLite3)
     add_library(SQLite3::SQLite3 ALIAS SQLite::SQLite3)
 endif()
 
+set(STATIC_FILES
+    "${CMAKE_CURRENT_SOURCE_DIR}/static/index.html"
+    "${CMAKE_CURRENT_SOURCE_DIR}/static/style.css"
+)
+include(cmake/embed_assets.cmake)
+
 set(SOURCE_FILES
+    src/app.cpp
+    "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp"
     src/data_source_sqlite.cpp
     src/probe.cpp
     src/socket_probe.cpp
@@ -34,6 +43,7 @@ set(SOURCE_FILES
     src/thread_pool.cpp
 )
 set(LIBS
+    mw::http-server
     mw::sqlite
     mw::url
     CURL::libcurl
@@ -63,6 +73,7 @@ if(BUILD_TESTING)
 
     set(TEST_FILES
         src/fake_probe.cpp
+        tests/app_test.cpp
         tests/data_source_sqlite_test.cpp
         tests/fake_probe_test.cpp
         tests/probe_test.cpp
diff --git a/cmake/embed_assets.cmake b/cmake/embed_assets.cmake
new file mode 100644
index 0000000..1155ce6
--- /dev/null
+++ b/cmake/embed_assets.cmake
@@ -0,0 +1,44 @@
+# Generate byte arrays without depending on external embedding tools.
+set(EMBEDDED_SOURCE "#include \"embedded_assets.h\"\n\nnamespace\n{\n")
+set(ASSET_ENTRIES "")
+set(ASSET_INDEX 0)
+foreach(ASSET IN LISTS STATIC_FILES)
+    set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${ASSET}")
+    file(READ "${ASSET}" ASSET_HEX HEX)
+    string(LENGTH "${ASSET_HEX}" ASSET_SIZE)
+    math(EXPR ASSET_SIZE "${ASSET_SIZE} / 2")
+    string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," ASSET_BYTES "${ASSET_HEX}")
+    string(APPEND EMBEDDED_SOURCE
+        "const unsigned char ASSET_${ASSET_INDEX}[] = {${ASSET_BYTES}0};\n")
+    file(RELATIVE_PATH ASSET_NAME "${CMAKE_CURRENT_SOURCE_DIR}/static"
+        "${ASSET}")
+    get_filename_component(ASSET_EXTENSION "${ASSET}" LAST_EXT)
+    if(ASSET_EXTENSION STREQUAL ".html")
+        set(ASSET_TYPE "text/html; charset=utf-8")
+    elseif(ASSET_EXTENSION STREQUAL ".css")
+        set(ASSET_TYPE "text/css; charset=utf-8")
+    elseif(ASSET_EXTENSION STREQUAL ".js")
+        set(ASSET_TYPE "text/javascript; charset=utf-8")
+    elseif(ASSET_EXTENSION STREQUAL ".svg")
+        set(ASSET_TYPE "image/svg+xml")
+    else()
+        set(ASSET_TYPE "application/octet-stream")
+    endif()
+    if(ASSET_NAME STREQUAL "index.html")
+        set(ASSET_PATH "/")
+    else()
+        set(ASSET_PATH "/static/${ASSET_NAME}")
+    endif()
+    string(APPEND ASSET_ENTRIES
+        "    {\"${ASSET_PATH}\", \"${ASSET_TYPE}\", {reinterpret_cast<const char*>(ASSET_${ASSET_INDEX}), ${ASSET_SIZE}}},\n")
+    math(EXPR ASSET_INDEX "${ASSET_INDEX} + 1")
+endforeach()
+string(APPEND EMBEDDED_SOURCE
+    "const EmbeddedAsset ASSETS[] = {\n${ASSET_ENTRIES}};\n}\n\n"
+    "std::span<const EmbeddedAsset> embeddedAssets()\n{\n    return ASSETS;\n}\n")
+file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated")
+file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
+    "${EMBEDDED_SOURCE}")
+configure_file(
+    "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp.tmp"
+    "${CMAKE_CURRENT_BINARY_DIR}/generated/embedded_assets.cpp" COPYONLY)
diff --git a/src/app.cpp b/src/app.cpp
new file mode 100644
index 0000000..f54e849
--- /dev/null
+++ b/src/app.cpp
@@ -0,0 +1,38 @@
+#include "app.h"
+
+#include <exception>
+#include <string>
+
+#include "embedded_assets.h"
+
+App::App(const ListenAddress& listen) : mw::HTTPServer(listen)
+{}
+
+void App::setup()
+{
+    server.set_exception_handler(unexpectedException);
+    server.Get("/.*", serveStatic);
+}
+
+void App::serveStatic(const Request& request, Response& response)
+{
+    for(const auto& asset : embeddedAssets())
+    {
+        if(request.path == asset.path)
+        {
+            response.set_content(asset.content.data(), asset.content.size(),
+                                 std::string(asset.content_type));
+            return;
+        }
+    }
+    response.status = 404;
+    response.set_content("Not found\n", "text/plain; charset=utf-8");
+}
+
+void App::unexpectedException([[maybe_unused]] const Request& request,
+                             [[maybe_unused]] Response& response,
+                             [[maybe_unused]] std::exception_ptr exception)
+    noexcept
+{
+    std::terminate();
+}
diff --git a/src/app.h b/src/app.h
new file mode 100644
index 0000000..9b50cae
--- /dev/null
+++ b/src/app.h
@@ -0,0 +1,23 @@
+#pragma once
+
+#include <exception>
+
+#include <mw/http_server.hpp>
+
+/// HTTP application serving the status tracker UI.
+class App : public mw::HTTPServer
+{
+public:
+    /// Configure the listening address. Call start() to begin serving.
+    explicit App(const ListenAddress& listen);
+
+protected:
+    /// Register HTTP routes before the server starts.
+    void setup() override;
+
+private:
+    static void serveStatic(const Request& request, Response& response);
+    static void unexpectedException(const Request& request,
+                                    Response& response,
+                                    std::exception_ptr exception) noexcept;
+};
diff --git a/src/embedded_assets.h b/src/embedded_assets.h
new file mode 100644
index 0000000..a22f04b
--- /dev/null
+++ b/src/embedded_assets.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <span>
+#include <string_view>
+
+/// One HTTP resource stored in the executable.
+struct EmbeddedAsset
+{
+    /// Exact request path for this resource.
+    std::string_view path;
+    /// HTTP content type, including charset for text resources.
+    std::string_view content_type;
+    /// Resource bytes, which may contain null bytes.
+    std::string_view content;
+};
+
+/// Access static resources with storage lasting for the process lifetime.
+std::span<const EmbeddedAsset> embeddedAssets();
diff --git a/src/main.cpp b/src/main.cpp
index d4fc6ec..4e0ee58 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,8 +1,19 @@
 #include <iostream>
 
+#include "app.h"
+
 // Entry point for the status tracker service.
 int main()
 {
-    std::cout << "Status Tracker\n";
+    App app(mw::IPSocketInfo{"127.0.0.1", 8080});
+    auto started = app.start();
+    if(!started)
+    {
+        std::cerr << started.error().msg() << '\n';
+        return 1;
+    }
+    std::cout << "Status Tracker listening on http://127.0.0.1:8080\n"
+              << std::flush;
+    app.wait();
     return 0;
 }
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..ef3045b
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,15 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Status Tracker</title>
+    <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+    <main>
+        <h1>Status Tracker</h1>
+        <p>Service status will appear here.</p>
+    </main>
+</body>
+</html>
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..6afb634
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,12 @@
+:root
+{
+    font-family: system-ui, sans-serif;
+    color-scheme: light dark;
+}
+
+main
+{
+    max-width: 60rem;
+    margin: 3rem auto;
+    padding: 0 1rem;
+}
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
new file mode 100644
index 0000000..8495cad
--- /dev/null
+++ b/tests/app_test.cpp
@@ -0,0 +1,86 @@
+#include "app.h"
+#include "embedded_assets.h"
+
+#include <thread>
+
+#include <gtest/gtest.h>
+
+namespace
+{
+
+class TestApp : public App
+{
+public:
+    TestApp() : App(mw::IPSocketInfo{"127.0.0.1", 0}) {}
+
+    int listen()
+    {
+        setup();
+        const int port = server.bind_to_any_port("127.0.0.1");
+        if(port >= 0)
+        {
+            worker = std::thread(&TestApp::serve, this);
+            server.wait_until_ready();
+        }
+        return port;
+    }
+
+    ~TestApp()
+    {
+        server.stop();
+        if(worker.joinable())
+        {
+            worker.join();
+        }
+    }
+
+private:
+    void serve()
+    {
+        server.listen_after_bind();
+    }
+
+    std::thread worker;
+};
+
+TEST(App, ServesEmbeddedAssets)
+{
+    TestApp app;
+    const int port = app.listen();
+    ASSERT_GT(port, 0);
+    httplib::Client client("127.0.0.1", port);
+    for(const auto& asset : embeddedAssets())
+    {
+        auto response = client.Get(std::string(asset.path) + "?v=1");
+        ASSERT_TRUE(response);
+        EXPECT_EQ(response->status, 200);
+        EXPECT_EQ(response->body, asset.content);
+        EXPECT_EQ(response->get_header_value("Content-Type"),
+                  asset.content_type);
+        auto head = client.Head(std::string(asset.path));
+        ASSERT_TRUE(head);
+        EXPECT_EQ(head->status, 200);
+        EXPECT_TRUE(head->body.empty());
+        EXPECT_EQ(head->get_header_value("Content-Length"),
+                  std::to_string(asset.content.size()));
+    }
+}
+
+TEST(App, RejectsUnknownPathsAndWrites)
+{
+    TestApp app;
+    const int port = app.listen();
+    ASSERT_GT(port, 0);
+    httplib::Client client("127.0.0.1", port);
+    for(const auto* path : {"/missing", "/static/missing.css", "/prd.md"})
+    {
+        auto response = client.Get(path);
+        ASSERT_TRUE(response);
+        EXPECT_EQ(response->status, 404);
+    }
+    auto response = client.Post("/static/style.css", "replace", "text/plain");
+    ASSERT_TRUE(response);
+    EXPECT_NE(response->status, 200);
+}
+
+}