# 1. Pre-built assets in SRC_DIST_DIR (manually built by user)
# 2. If BUILD_UI=ON: npm build
# 3. If above did not produce assets and HF_ENABLED=ON: HF Bucket download
+# of dist.tar.gz (verified against dist.tar.gz.sha256)
-cmake_minimum_required(VERSION 3.16)
+cmake_minimum_required(VERSION 3.18)
set(UI_SOURCE_DIR "" CACHE STRING "UI source directory (to run npm build)")
set(UI_BINARY_DIR "" CACHE STRING "UI binary directory (to store generated files)")
set(BUILD_UI "" CACHE STRING "Build UI via npm (ON/OFF)")
set(LLAMA_UI_EMBED "" CACHE STRING "Path to llama-ui-embed helper")
-# IMPORTANT: When adding PWA assets, sync across all 3 places:
-# 1. tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES, PUBLIC_ENDPOINTS)
-# 2. tools/server/server-http.cpp (public_endpoints)
-# 3. scripts/ui-assets.cmake (ASSETS list)
-# - C++ (server-http.cpp) - public endpoints (splash screens generated via helper)
-# - TypeScript (constants/pwa.ts) - APPLE_DEVICES, PWA_MANIFEST, PUBLIC_ENDPOINTS
+# IMPORTANT: When adding PWA assets, sync:
+# - tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES, PWA_MANIFEST)
#
-# When adding/changing PWA assets, update tools/ui/src/lib/constants/pwa.ts first,
-# then sync any new file names here and in server-http.cpp.
-set(ASSETS
+# The HTTP server registers routes and public endpoints for every embedded asset.
+set(REQUIRED_ASSETS
index.html
loading.html
- # PWA assets
- favicon.ico
- favicon-dark.ico
- favicon.svg
- favicon-dark.svg
- pwa-64x64.png
- pwa-192x192.png
- pwa-512x512.png
- maskable-icon-512x512.png
- apple-touch-icon-180x180.png
- # iOS splash screens
- apple-splash-portrait-640x1136.png
- apple-splash-landscape-1136x640.png
- apple-splash-portrait-750x1334.png
- apple-splash-landscape-1334x750.png
- apple-splash-portrait-1170x2532.png
- apple-splash-landscape-2532x1170.png
- apple-splash-portrait-1179x2556.png
- apple-splash-landscape-2556x1179.png
- apple-splash-portrait-1206x2622.png
- apple-splash-landscape-2622x1206.png
- apple-splash-portrait-1284x2778.png
- apple-splash-landscape-2778x1284.png
- apple-splash-portrait-1290x2796.png
- apple-splash-landscape-2796x1290.png
- apple-splash-portrait-1320x2868.png
- apple-splash-landscape-2868x1320.png
- apple-splash-portrait-1488x2266.png
- apple-splash-landscape-2266x1488.png
- apple-splash-portrait-1640x2360.png
- apple-splash-landscape-2360x1640.png
- apple-splash-portrait-1668x2388.png
- apple-splash-landscape-2388x1668.png
- apple-splash-portrait-2048x2732.png
- apple-splash-landscape-2732x2048.png
- # iOS dark splash screens
- apple-splash-portrait-dark-640x1136.png
- apple-splash-landscape-dark-1136x640.png
- apple-splash-portrait-dark-750x1334.png
- apple-splash-landscape-dark-1334x750.png
- apple-splash-portrait-dark-1170x2532.png
- apple-splash-landscape-dark-2532x1170.png
- apple-splash-portrait-dark-1179x2556.png
- apple-splash-landscape-dark-2556x1179.png
- apple-splash-portrait-dark-1206x2622.png
- apple-splash-landscape-dark-2622x1206.png
- apple-splash-portrait-dark-1284x2778.png
- apple-splash-landscape-dark-2778x1284.png
- apple-splash-portrait-dark-1290x2796.png
- apple-splash-landscape-dark-2796x1290.png
- apple-splash-portrait-dark-1320x2868.png
- apple-splash-landscape-dark-2868x1320.png
- apple-splash-portrait-dark-1640x2360.png
- apple-splash-landscape-dark-2360x1640.png
- apple-splash-portrait-dark-1668x2388.png
- apple-splash-landscape-dark-2388x1668.png
- apple-splash-portrait-dark-2048x2732.png
- apple-splash-landscape-dark-2732x2048.png
manifest.webmanifest
sw.js
- _app/version.json
build.json
+ # post-build.js flattens and dehashes these to fixed names in the dist dir
+ bundle.js
+ bundle.css
+ workbox.js
+ version.json
)
set(DIST_DIR "${UI_BINARY_DIR}/dist")
set(UI_CPP "${UI_BINARY_DIR}/ui.cpp")
set(UI_H "${UI_BINARY_DIR}/ui.h")
-function(assets_present out_var)
+function(assets_present dir out_var)
set(present TRUE)
- foreach(asset ${ASSETS})
- if(NOT EXISTS "${DIST_DIR}/${asset}")
+ foreach(asset ${REQUIRED_ASSETS})
+ if(NOT EXISTS "${dir}/${asset}")
set(present FALSE)
break()
endif()
set(${out_var} ${present} PARENT_SCOPE)
endfunction()
-function(copy_src_dist out_var)
- set(${out_var} FALSE PARENT_SCOPE)
-
- foreach(asset ${ASSETS})
- if(NOT EXISTS "${SRC_DIST_DIR}/${asset}")
- return()
- endif()
- endforeach()
-
- file(MAKE_DIRECTORY "${DIST_DIR}")
- message(STATUS "UI: using pre-built assets from ${SRC_DIST_DIR}")
- foreach(asset ${ASSETS})
- execute_process(
- COMMAND ${CMAKE_COMMAND} -E copy_if_different
- "${SRC_DIST_DIR}/${asset}" "${DIST_DIR}/${asset}"
- )
- endforeach()
- set(${out_var} TRUE PARENT_SCOPE)
-endfunction()
-
function(npm_build_should_skip out_var)
set(${out_var} FALSE PARENT_SCOPE)
- assets_present(present)
+ assets_present("${DIST_DIR}" present)
if(NOT present)
return()
endif()
return()
endif()
- assets_present(present)
+ assets_present("${DIST_DIR}" present)
if(NOT present)
message(STATUS "UI: npm build finished but assets missing in ${DIST_DIR}")
return()
set(${out_var} FALSE PARENT_SCOPE)
set(${out_resolved} "" PARENT_SCOPE)
- file(MAKE_DIRECTORY "${DIST_DIR}")
+ set(archive "${UI_BINARY_DIR}/dist.tar.gz")
set(candidates "")
if(NOT "${version}" STREQUAL "")
list(APPEND candidates "latest")
foreach(resolved ${candidates})
- set(base "https://huggingface.co/buckets/ggml-org/${HF_BUCKET}/resolve/${resolved}")
-
- message(STATUS "UI: downloading from ${resolved}: ${base}")
-
- set(ok TRUE)
- foreach(asset ${ASSETS})
- file(DOWNLOAD "${base}/${asset}?download=true" "${DIST_DIR}/${asset}"
- STATUS status TIMEOUT 60
- )
- list(GET status 0 rc)
- if(NOT rc EQUAL 0)
- list(GET status 1 errmsg)
- message(STATUS "UI: download ${asset} from ${resolved} failed: ${errmsg}")
- set(ok FALSE)
- break()
- endif()
- message(STATUS "UI: downloaded ${asset}")
- endforeach()
-
- if(NOT ok)
+ set(base "https://huggingface.co/buckets/${HF_BUCKET}/resolve/${resolved}")
+
+ message(STATUS "UI: downloading from ${resolved}: ${base}/dist.tar.gz")
+
+ file(DOWNLOAD "${base}/dist.tar.gz?download=true" "${archive}"
+ STATUS status TIMEOUT 300
+ )
+ list(GET status 0 rc)
+ if(NOT rc EQUAL 0)
+ list(GET status 1 errmsg)
+ message(STATUS "UI: download dist.tar.gz from ${resolved} failed: ${errmsg}")
continue()
endif()
- # Best-effort checksum verification
- file(DOWNLOAD "${base}/checksums.txt?download=true" "${DIST_DIR}/checksums.txt"
- STATUS cs_status TIMEOUT 30
+ file(DOWNLOAD "${base}/dist.tar.gz.sha256?download=true" "${archive}.sha256"
+ STATUS status TIMEOUT 30
)
- list(GET cs_status 0 cs_rc)
- if(cs_rc EQUAL 0)
- message(STATUS "UI: verifying checksums")
- file(STRINGS "${DIST_DIR}/checksums.txt" cs_lines)
- foreach(asset ${ASSETS})
- file(SHA256 "${DIST_DIR}/${asset}" h)
- string(TOLOWER "${h}" h)
- string(REGEX MATCH "${h}[ \t]+${asset}" m "${cs_lines}")
- if(NOT m)
- message(WARNING "UI: checksum verification failed for ${asset}")
- set(ok FALSE)
- break()
- endif()
- endforeach()
- if(ok)
- message(STATUS "UI: all checksums verified")
- endif()
+ list(GET status 0 rc)
+ if(NOT rc EQUAL 0)
+ list(GET status 1 errmsg)
+ message(STATUS "UI: download dist.tar.gz.sha256 from ${resolved} failed: ${errmsg}")
+ continue()
endif()
- if(ok)
- set(${out_var} TRUE PARENT_SCOPE)
- set(${out_resolved} "${resolved}" PARENT_SCOPE)
- return()
+ # Validate sha256 checkums
+ file(READ "${archive}.sha256" expected)
+ string(REGEX MATCH "^[0-9a-fA-F]+" expected "${expected}")
+ string(TOLOWER "${expected}" expected)
+ file(SHA256 "${archive}" actual)
+ if("${expected}" STREQUAL "" OR NOT "${actual}" STREQUAL "${expected}")
+ message(STATUS "UI: checksum mismatch for dist.tar.gz from ${resolved}")
+ continue()
+ endif()
+
+ # Clear DIST_DIR to remove stale files first
+ file(REMOVE_RECURSE "${DIST_DIR}")
+
+ file(ARCHIVE_EXTRACT INPUT "${archive}" DESTINATION "${DIST_DIR}")
+
+ assets_present("${DIST_DIR}" present)
+ if(NOT present)
+ message(STATUS "UI: archive from ${resolved} is missing required assets")
+ continue()
endif()
+
+ message(STATUS "UI: archive verified and extracted")
+ set(${out_var} TRUE PARENT_SCOPE)
+ set(${out_resolved} "${resolved}" PARENT_SCOPE)
+ return()
endforeach()
endfunction()
-function(emit_files)
- assets_present(present)
+function(emit_files dist_dir)
+ assets_present("${dist_dir}" present)
set(args "${UI_CPP}" "${UI_H}")
if(present)
- foreach(asset ${ASSETS})
- list(APPEND args "${asset}" "${DIST_DIR}/${asset}")
- endforeach()
-
- # Bundle files live in _app/immutable/ — vanilla SvelteKit output, no plugin
- # rewriting. Embedded names must match the exact _app/ paths that index.html
- # and sw.js reference.
- file(GLOB_RECURSE detected_bundle_js "${DIST_DIR}/_app/immutable/bundle.*.js")
- file(GLOB_RECURSE detected_bundle_css "${DIST_DIR}/_app/immutable/assets/bundle.*.css")
- file(GLOB_RECURSE detected_workbox "${DIST_DIR}/workbox-*.js")
- # Compute relative path from DIST_DIR to each found file.
- # e.g. /path/to/build/tools/ui/dist/_app/immutable/bundle.XXX.js
- # -> _app/immutable/bundle.XXX.js
- foreach(f ${detected_bundle_js})
- string(REPLACE "${DIST_DIR}/" "" rel "${f}")
- list(APPEND args "${rel}" "${f}")
- endforeach()
- foreach(f ${detected_bundle_css})
- string(REPLACE "${DIST_DIR}/" "" rel "${f}")
- list(APPEND args "${rel}" "${f}")
- endforeach()
- foreach(f ${detected_workbox})
- string(REPLACE "${DIST_DIR}/" "" rel "${f}")
- list(APPEND args "${rel}" "${f}")
- endforeach()
+ # llama-ui-embed embeds every top-level file in dist_dir
+ list(APPEND args "${dist_dir}")
endif()
- # Create build.json with the llama.cpp build number for UI version display.
- # This is separate from SvelteKit's _app/version.json (used for SW cache invalidation).
- # build.json is generated by the vite plugin (buildInfoPlugin) during npm build.
- # CMake just embeds it from the dist that npm produced.
-
execute_process(
COMMAND "${LLAMA_UI_EMBED}" ${args}
RESULT_VARIABLE rc
# ---------------------------------------------------------------------------
# 1. Priority 1: pre-built assets supplied in tools/ui/dist
# ---------------------------------------------------------------------------
-copy_src_dist(SRC_OK)
+assets_present("${SRC_DIST_DIR}" SRC_OK)
if(SRC_OK)
- emit_files()
+ message(STATUS "UI: using pre-built assets from ${SRC_DIST_DIR}")
+ emit_files("${SRC_DIST_DIR}")
return()
endif()
endif()
endif()
- assets_present(have_assets)
+ assets_present("${DIST_DIR}" have_assets)
if(stamp_ok AND have_assets)
message(STATUS "UI: HF stamp '${stamped}' matches version, skipping HF fetch")
set(provisioned TRUE)
# 4. Fallback: warn about stale or missing assets, then emit whatever we have
# ---------------------------------------------------------------------------
if(NOT provisioned)
- assets_present(have_assets)
+ assets_present("${DIST_DIR}" have_assets)
if(have_assets)
message(WARNING "UI: provisioning failed; embedding stale assets from ${DIST_DIR}")
else()
endif()
endif()
-emit_files()
+emit_files("${DIST_DIR}")
server_http_context::~server_http_context() = default;
+// transform path --> asset name ; rules:
+// delete "_app/" prefix
+// delete hash, for ex: bundle.HCjcCZFH.css --> bundle.css
+// workbox-12bd46aa.js --> workbox.js
+static std::string asset_name_from_path(const std::string & path) {
+ // Strip leading slash
+ std::string s = (!path.empty() && path[0] == '/') ? path.substr(1) : path;
+ // Strip _app/ prefix
+ if (s.size() > 5 && s.compare(0, 5, "_app/") == 0) {
+ s = s.substr(5);
+ }
+ // Strip hash segment from filename:
+ // bundle.HCjcCZFH.css -> bundle.css (name.HASH.ext)
+ // workbox-12bd46aa.js -> workbox.js (name-HEXHASH.ext)
+ size_t slash = s.rfind('/');
+ std::string dir = (slash != std::string::npos) ? s.substr(0, slash + 1) : "";
+ std::string file = (slash != std::string::npos) ? s.substr(slash + 1) : s;
+
+ auto is_alnum_hash = [](const std::string & h) {
+ if (h.size() < 6 || h.size() > 16) return false;
+ for (char c : h) { if (!isalnum((unsigned char)c)) return false; }
+ return true;
+ };
+ auto is_hex_hash = [](const std::string & h) {
+ if (h.size() < 6 || h.size() > 16) return false;
+ for (char c : h) { if (!isxdigit((unsigned char)c)) return false; }
+ return true;
+ };
+
+ size_t dot1 = file.find('.');
+ if (dot1 != std::string::npos) {
+ size_t dot2 = file.find('.', dot1 + 1);
+ if (dot2 != std::string::npos && is_alnum_hash(file.substr(dot1 + 1, dot2 - dot1 - 1))) {
+ file = file.substr(0, dot1) + file.substr(dot2);
+ } else {
+ size_t dot = file.rfind('.');
+ size_t dash = file.rfind('-', dot);
+ if (dash != std::string::npos && is_hex_hash(file.substr(dash + 1, dot - dash - 1))) {
+ file = file.substr(0, dash) + file.substr(dot);
+ }
+ }
+ }
+
+ return dir + file;
+}
+
static void log_server_request(const httplib::Request & req, const httplib::Response & res) {
// skip logging requests that are regularly sent, to avoid log spam
if (req.path == "/health"
SRV_INF("api_keys: %zu keys loaded\n", params.api_keys.size());
}
- //
- // Helper: Generate iOS splash screen paths from device dimensions
- // This centralizes PWA asset paths to avoid duplication across CMake, C++, and TypeScript.
- // Source of truth: tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES)
- //
- auto generate_splash_endpoints = []() -> std::vector<std::string> {
- // Apple device dimensions (width x height) with orientation and color scheme
- // Format: "orientation-dimension1xdimension2.png" or "orientation-dark-dimension1xdimension2.png"
- // Based on https://developer.apple.com/design/human-interface-guidelines/app-icons
- static const std::vector<std::pair<std::string, std::string>> splash_specs = {
- // Portrait screens (light)
- {"portrait", "640x1136"}, {"portrait", "750x1334"},
- {"portrait", "1170x2532"}, {"portrait", "1179x2556"},
- {"portrait", "1206x2622"}, {"portrait", "1284x2778"},
- {"portrait", "1290x2796"}, {"portrait", "1320x2868"},
- {"portrait", "1488x2266"}, {"portrait", "1640x2360"},
- {"portrait", "1668x2388"}, {"portrait", "2048x2732"},
- // Landscape screens (light) - dimensions swapped
- {"landscape", "1136x640"}, {"landscape", "1334x750"},
- {"landscape", "2532x1170"}, {"landscape", "2556x1179"},
- {"landscape", "2622x1206"}, {"landscape", "2778x1284"},
- {"landscape", "2796x1290"}, {"landscape", "2868x1320"},
- {"landscape", "2266x1488"}, {"landscape", "2360x1640"},
- {"landscape", "2388x1668"}, {"landscape", "2732x2048"},
- // Portrait screens (dark)
- {"portrait-dark", "640x1136"}, {"portrait-dark", "750x1334"},
- {"portrait-dark", "1170x2532"}, {"portrait-dark", "1179x2556"},
- {"portrait-dark", "1206x2622"}, {"portrait-dark", "1284x2778"},
- {"portrait-dark", "1290x2796"}, {"portrait-dark", "1320x2868"},
- {"portrait-dark", "1488x2266"}, {"portrait-dark", "1640x2360"},
- {"portrait-dark", "1668x2388"}, {"portrait-dark", "2048x2732"},
- // Landscape screens (dark)
- {"landscape-dark", "1136x640"}, {"landscape-dark", "1334x750"},
- {"landscape-dark", "2532x1170"}, {"landscape-dark", "2556x1179"},
- {"landscape-dark", "2622x1206"}, {"landscape-dark", "2778x1284"},
- {"landscape-dark", "2796x1290"}, {"landscape-dark", "2868x1320"},
- {"landscape-dark", "2266x1488"}, {"landscape-dark", "2360x1640"},
- {"landscape-dark", "2388x1668"}, {"landscape-dark", "2732x2048"}
- };
-
- std::vector<std::string> endpoints;
- endpoints.reserve(splash_specs.size());
- for (const auto & [orientation, dimensions] : splash_specs) {
- endpoints.push_back("/apple-splash-" + orientation + "-" + dimensions + ".png");
- }
- return endpoints;
- };
-
//
// Middlewares
//
- // Public endpoints list - includes health, UI, and PWA assets
- // Source of truth for splash screen paths: tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES)
- static const std::unordered_set<std::string> get_public_endpoints = [generate_splash_endpoints]() {
+ // Public endpoints - API routes plus all embedded UI assets
+ static const std::unordered_set<std::string> get_public_endpoints = []() {
std::unordered_set<std::string> endpoints {
"/health",
"/v1/health",
"/models",
"/v1/models",
"/",
- "/index.html",
- // PWA assets
- "/favicon.ico",
- "/favicon-dark.ico",
- "/favicon.svg",
- "/favicon-dark.svg",
- "/pwa-64x64.png",
- "/pwa-192x192.png",
- "/pwa-512x512.png",
- "/maskable-icon-512x512.png",
- "/apple-touch-icon-180x180.png",
- // iOS splash screens (generated from APPLE_DEVICES in TypeScript)
- // PWA runtime files
- "/manifest.webmanifest",
- "/sw.js",
- "/version.json",
- "/workbox-<hash>.js",
- "/_app/version.json",
- "/build.json"
};
- // Add all splash screen endpoints
- auto splash = generate_splash_endpoints();
- for (const auto & path : splash) {
- endpoints.insert(path);
+ for (const llama_ui_asset & a : llama_ui_get_assets()) {
+ endpoints.insert("/" + a.name);
}
return endpoints;
}();
return true;
}
- // If path is public or static file, skip validation
- if (get_public_endpoints.find(req.path) != get_public_endpoints.end()) {
- return true;
- }
- // Static assets (_app/ files, workbox runtime). These are embedded at build time
- // so no API key is needed — browsers fetch them directly.
- if (req.path.find("/_app/") == 0 || req.path.find("/workbox-") == 0) {
+ // If path is public or a UI asset (including hashed paths like /_app/bundle.XXX.js),
+ // skip validation
+ if (get_public_endpoints.count("/" + asset_name_from_path(req.path))) {
return true;
}
}
} else {
#if defined(LLAMA_UI_HAS_ASSETS)
- // Embedded assets are immutable — cache aggressively for PWA/sw offline support.
- // PWA runtime files (sw.js, manifest, version.json) use no-cache for revalidation.
- // Bundle files use Vite content hashes (bundle.<hash>.js/css) so each build
- // produces a different filename — browsers naturally get a fresh copy on upgrade.
- auto serve_asset_cached = [](const std::string & name, const char * mime, bool with_isolation_headers) {
- return [name, mime, with_isolation_headers](const httplib::Request & req, httplib::Response & res) {
- const llama_ui_asset * a = llama_ui_find_asset(name.c_str());
- if (!a) {
- res.status = 404;
- return false;
- }
+ auto serve_asset_cached = [](const std::string & name, bool isolation) {
+ return [name, isolation](const httplib::Request & req, httplib::Response & res) {
+ const llama_ui_asset * a = llama_ui_find_asset(name);
+ if (!a) { res.status = 404; return false; }
res.set_header("ETag", a->etag);
- // Check If-None-Match for conditional GET (304 Not Modified)
if (const std::string & inm = req.get_header_value("If-None-Match");
!inm.empty() && (inm == a->etag || inm == std::string("W/") + a->etag)) {
res.status = 304;
return false;
}
- if (with_isolation_headers) {
- // COEP and COOP headers, required by pyodide (python interpreter)
+ if (isolation) {
res.set_header("Cross-Origin-Embedder-Policy", "require-corp");
- res.set_header("Cross-Origin-Opener-Policy", "same-origin");
+ res.set_header("Cross-Origin-Opener-Policy", "same-origin");
}
res.set_header("Cache-Control", "public, max-age=31536000, immutable");
- res.set_content(reinterpret_cast<const char*>(a->data), a->size, mime);
+ res.set_content(reinterpret_cast<const char*>(a->data), a->size, a->type.c_str());
return false;
};
};
- auto serve_asset_nocache = [](const std::string & name, const char * mime, bool with_isolation_headers) {
- return [name, mime, with_isolation_headers](const httplib::Request & /*req*/, httplib::Response & res) {
- const llama_ui_asset * a = llama_ui_find_asset(name.c_str());
+ auto serve_asset_nocache = [](const std::string & name) {
+ return [name](const httplib::Request & /*req*/, httplib::Response & res) {
+ const llama_ui_asset * a = llama_ui_find_asset(name);
if (!a) {
res.status = 404;
return false;
}
- if (with_isolation_headers) {
- res.set_header("Cross-Origin-Embedder-Policy", "require-corp");
- res.set_header("Cross-Origin-Opener-Policy", "same-origin");
- }
res.set_header("Cache-Control", "no-cache");
- res.set_content(reinterpret_cast<const char*>(a->data), a->size, mime);
+ res.set_content(reinterpret_cast<const char*>(a->data), a->size, a->type.c_str());
return false;
};
};
- // Bundle files in _app/immutable/ — SvelteKit outputs them here and index.html
- // and sw.js reference them via these paths (vanilla build, no plugin).
- auto serve_bundle = [serve_asset_cached](const httplib::Request & req, httplib::Response & res) {
- std::string path = req.path;
- std::string name;
- const char * mime;
- if (path.rfind("/_app/immutable/bundle.", 0) == 0 && path.size() > 22) {
- name = path.substr(1); // strip leading /
- mime = "application/javascript; charset=utf-8";
- } else if (path.rfind("/_app/immutable/assets/bundle.", 0) == 0 && path.size() > 30) {
- name = path.substr(1); // strip leading /
- mime = "text/css; charset=utf-8";
- } else {
- res.status = 404;
- return false;
- }
- return serve_asset_cached(name, mime, false)(req, res);
+ // Hashed routes: browser requests contain the build hash, assets are stored without.
+ auto serve_hashed = [serve_asset_cached](const std::string & name) {
+ return serve_asset_cached(name, false);
};
+ srv->Get(params.api_prefix + R"(/_app/immutable/bundle\.[^/]+\.js)", serve_hashed("bundle.js"));
+ srv->Get(params.api_prefix + R"(/_app/immutable/assets/bundle\.[^/]+\.css)", serve_hashed("bundle.css"));
+ srv->Get(params.api_prefix + R"(/workbox-[^/]+\.js)", serve_hashed("workbox.js"));
+
+ // SPA entry — also aliased at "/_app/version.json" (referenced by the service worker)
+ srv->Get(params.api_prefix + "/", serve_asset_cached ("index.html", true));
+ srv->Get(params.api_prefix + "/_app/version.json", serve_asset_nocache("version.json"));
+
+ // All remaining assets registered directly from the embedded asset table.
+ // PWA revalidation files (sw.js, manifest, version.json) use no-cache;
+ // everything else is immutable.
+ static const std::unordered_set<std::string> no_cache_names = {
+ "sw.js",
+ "manifest.webmanifest",
+ "version.json",
+ "build.json"
+ };
+ // index.html also accessible at /index.html (with the same isolation headers as /)
+ srv->Get(params.api_prefix + "/index.html", serve_asset_cached("index.html", true));
- // _app/ paths — vanilla SvelteKit output, index.html and sw.js reference
- // bundles and version.json here directly.
- srv->Get(params.api_prefix + R"(/_app/immutable/bundle\.[^/]+\.js)", serve_bundle);
- srv->Get(params.api_prefix + R"(/_app/immutable/assets/bundle\.[^/]+\.css)", serve_bundle);
- srv->Get(params.api_prefix + "/_app/version.json", serve_asset_cached("_app/version.json", "application/json; charset=utf-8", false));
-
- auto serve_workbox = [serve_asset_cached](const httplib::Request & req, httplib::Response & res) {
- std::string name = req.path.substr(1);
- if (name.rfind("workbox-", 0) == 0 && name.size() > 10) {
- return serve_asset_cached(name, "application/javascript; charset=utf-8", false)(req, res);
+ for (const auto & a : llama_ui_get_assets()) {
+ if (a.name == "index.html") continue; // served at "/" and "/index.html" above
+ if (no_cache_names.count(a.name)) {
+ srv->Get(params.api_prefix + "/" + a.name, serve_asset_nocache(a.name));
+ } else {
+ srv->Get(params.api_prefix + "/" + a.name, serve_asset_cached(a.name, false));
}
- res.status = 404;
- return false;
- };
- srv->Get(params.api_prefix + R"(/workbox-[^/]+\.js)", serve_workbox);
- srv->Get(params.api_prefix + R"(/sw\.js)", serve_asset_cached("sw.js", "application/javascript; charset=utf-8", false));
- srv->Get(params.api_prefix + "/manifest.webmanifest", serve_asset_cached("manifest.webmanifest", "application/manifest+json; charset=utf-8", false));
- srv->Get(params.api_prefix + "/version.json", serve_asset_cached("_app/version.json", "application/json; charset=utf-8", false));
- srv->Get(params.api_prefix + "/build.json", serve_asset_cached("build.json", "application/json; charset=utf-8", false));
-
- // Finally serve index.html for all other routes (SPA fallback)
- srv->Get(params.api_prefix + "/", serve_asset_cached("index.html", "text/html; charset=utf-8", true));
- srv->Get(params.api_prefix + "/favicon.ico", serve_asset_cached("favicon.ico", "image/x-icon", false));
- srv->Get(params.api_prefix + "/favicon-dark.ico", serve_asset_cached("favicon-dark.ico", "image/x-icon", false));
- srv->Get(params.api_prefix + "/favicon.svg", serve_asset_cached("favicon.svg", "image/svg+xml", false));
- srv->Get(params.api_prefix + "/favicon-dark.svg", serve_asset_cached("favicon-dark.svg", "image/svg+xml", false));
- srv->Get(params.api_prefix + "/pwa-64x64.png", serve_asset_cached("pwa-64x64.png", "image/png", false));
- srv->Get(params.api_prefix + "/pwa-192x192.png", serve_asset_cached("pwa-192x192.png", "image/png", false));
- srv->Get(params.api_prefix + "/pwa-512x512.png", serve_asset_cached("pwa-512x512.png", "image/png", false));
- srv->Get(params.api_prefix + "/maskable-icon-512x512.png", serve_asset_cached("maskable-icon-512x512.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-touch-icon-180x180.png", serve_asset_cached("apple-touch-icon-180x180.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-640x1136.png", serve_asset_cached("apple-splash-portrait-640x1136.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-1136x640.png", serve_asset_cached("apple-splash-landscape-1136x640.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-750x1334.png", serve_asset_cached("apple-splash-portrait-750x1334.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-1334x750.png", serve_asset_cached("apple-splash-landscape-1334x750.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-1170x2532.png", serve_asset_cached("apple-splash-portrait-1170x2532.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2532x1170.png", serve_asset_cached("apple-splash-landscape-2532x1170.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-1179x2556.png", serve_asset_cached("apple-splash-portrait-1179x2556.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2556x1179.png", serve_asset_cached("apple-splash-landscape-2556x1179.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-1206x2622.png", serve_asset_cached("apple-splash-portrait-1206x2622.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2622x1206.png", serve_asset_cached("apple-splash-landscape-2622x1206.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-1284x2778.png", serve_asset_cached("apple-splash-portrait-1284x2778.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2778x1284.png", serve_asset_cached("apple-splash-landscape-2778x1284.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-1290x2796.png", serve_asset_cached("apple-splash-portrait-1290x2796.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2796x1290.png", serve_asset_cached("apple-splash-landscape-2796x1290.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-1320x2868.png", serve_asset_cached("apple-splash-portrait-1320x2868.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2868x1320.png", serve_asset_cached("apple-splash-landscape-2868x1320.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-1488x2266.png", serve_asset_cached("apple-splash-portrait-1488x2266.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2266x1488.png", serve_asset_cached("apple-splash-landscape-2266x1488.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-1640x2360.png", serve_asset_cached("apple-splash-portrait-1640x2360.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2360x1640.png", serve_asset_cached("apple-splash-landscape-2360x1640.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-1668x2388.png", serve_asset_cached("apple-splash-portrait-1668x2388.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2388x1668.png", serve_asset_cached("apple-splash-landscape-2388x1668.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-2048x2732.png", serve_asset_cached("apple-splash-portrait-2048x2732.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-2732x2048.png", serve_asset_cached("apple-splash-landscape-2732x2048.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-640x1136.png", serve_asset_cached("apple-splash-portrait-dark-640x1136.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-1136x640.png", serve_asset_cached("apple-splash-landscape-dark-1136x640.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-750x1334.png", serve_asset_cached("apple-splash-portrait-dark-750x1334.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-1334x750.png", serve_asset_cached("apple-splash-landscape-dark-1334x750.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1170x2532.png", serve_asset_cached("apple-splash-portrait-dark-1170x2532.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2532x1170.png", serve_asset_cached("apple-splash-landscape-dark-2532x1170.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1179x2556.png", serve_asset_cached("apple-splash-portrait-dark-1179x2556.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2556x1179.png", serve_asset_cached("apple-splash-landscape-dark-2556x1179.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1206x2622.png", serve_asset_cached("apple-splash-portrait-dark-1206x2622.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2622x1206.png", serve_asset_cached("apple-splash-landscape-dark-2622x1206.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1284x2778.png", serve_asset_cached("apple-splash-portrait-dark-1284x2778.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2778x1284.png", serve_asset_cached("apple-splash-landscape-dark-2778x1284.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1290x2796.png", serve_asset_cached("apple-splash-portrait-dark-1290x2796.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2796x1290.png", serve_asset_cached("apple-splash-landscape-dark-2796x1290.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1320x2868.png", serve_asset_cached("apple-splash-portrait-dark-1320x2868.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2868x1320.png", serve_asset_cached("apple-splash-landscape-dark-2868x1320.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1640x2360.png", serve_asset_cached("apple-splash-portrait-dark-1640x2360.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2360x1640.png", serve_asset_cached("apple-splash-landscape-dark-2360x1640.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1668x2388.png", serve_asset_cached("apple-splash-portrait-dark-1668x2388.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2388x1668.png", serve_asset_cached("apple-splash-landscape-dark-2388x1668.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-portrait-dark-2048x2732.png", serve_asset_cached("apple-splash-portrait-dark-2048x2732.png", "image/png", false));
- srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2732x2048.png", serve_asset_cached("apple-splash-landscape-dark-2732x2048.png", "image/png", false));
- srv->Get(params.api_prefix + "/manifest.webmanifest", serve_asset_nocache("manifest.webmanifest", "application/manifest+json", false));
- srv->Get(params.api_prefix + "/sw.js", serve_asset_nocache("sw.js", "application/javascript; charset=utf-8", false));
- srv->Get(params.api_prefix + "/version.json", serve_asset_nocache("version.json", "application/json", false));
+ }
#endif
}
// llama-ui-embed: generate ui.cpp / ui.h that embed UI assets as C arrays.
//
// Usage:
-// llama-ui-embed <out_cpp> <out_h> [<asset_name> <asset_path>]...
+// llama-ui-embed <out_cpp> <out_h> [<asset_dir>]
+//
+// Embeds every regular file directly under <asset_dir> (non-recursive).
+// Without <asset_dir>, emits an empty asset table.
+#include <inttypes.h>
#include <stdarg.h>
+#include <stdint.h>
#include <stdio.h>
#include <string.h>
+
+#include <algorithm>
+#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
-#include <cinttypes>
-#include <cstdint>
+
+
+static const char * mime_from_ext(const std::string & name) {
+ auto ext = name.rfind('.');
+ if (ext == std::string::npos) return "application/octet-stream";
+ std::string e = name.substr(ext + 1);
+ if (e == "html") return "text/html; charset=utf-8";
+ if (e == "css") return "text/css";
+ if (e == "js") return "application/javascript";
+ if (e == "json") return "application/json";
+ if (e == "webmanifest") return "application/manifest+json";
+ if (e == "svg") return "image/svg+xml";
+ if (e == "png") return "image/png";
+ if (e == "jpg" ||
+ e == "jpeg") return "image/jpeg";
+ if (e == "ico") return "image/x-icon";
+ if (e == "woff") return "font/woff";
+ if (e == "woff2") return "font/woff2";
+ return "application/octet-stream";
+}
// Computes FNV-1a hash of the data
static uint64_t fnv_hash(const uint8_t * data, size_t len) {
return hash;
}
-static bool read_file(const std::string & path, std::vector<unsigned char> & out) {
+static bool read_file(const std::filesystem::path & path, std::vector<unsigned char> & out) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) {
- fprintf(stderr, "embed: cannot open %s\n", path.c_str());
+ fprintf(stderr, "embed: cannot open %s\n", path.string().c_str());
return false;
}
const auto sz = f.tellg();
return (n > 0) ? std::string(tmp, static_cast<size_t>(n)) : std::string();
}
+struct asset_entry {
+ std::string name;
+ std::filesystem::path path;
+};
+
int main(int argc, char ** argv) {
- if (argc < 3 || ((argc - 3) % 2) != 0) {
- fprintf(stderr, "usage: %s <out_cpp> <out_h> [<name> <path>]...\n", argv[0]);
+ if (argc < 3 || argc > 4) {
+ fprintf(stderr, "usage: %s <out_cpp> <out_h> [<asset_dir>]\n", argv[0]);
return 1;
}
const std::string out_cpp = argv[1];
const std::string out_h = argv[2];
- const int n_assets = (argc - 3) / 2;
+
+ std::vector<asset_entry> assets;
+ if (argc == 4) {
+ const std::filesystem::path dir = argv[3];
+
+ std::error_code ec;
+ std::filesystem::directory_iterator it(dir, ec);
+ if (ec) {
+ fprintf(stderr, "embed: cannot iterate %s: %s\n", argv[3], ec.message().c_str());
+ return 1;
+ }
+ for (const auto & entry : it) {
+ if (!entry.is_regular_file()) {
+ continue;
+ }
+ assets.push_back({ entry.path().filename().generic_string(), entry.path() });
+ }
+
+ // directory iteration order is unspecified; sort for reproducible output
+ std::sort(assets.begin(), assets.end(),
+ [](const asset_entry & a, const asset_entry & b) { return a.name < b.name; });
+ }
+
+ const int n_assets = static_cast<int>(assets.size());
+
+ if (n_assets > 0) {
+ bool has_index = false, has_bundle_js = false, has_bundle_css = false, has_version = false;
+ for (const auto & a : assets) {
+ if (a.name == "index.html") has_index = true;
+ if (a.name == "bundle.js") has_bundle_js = true;
+ if (a.name == "bundle.css") has_bundle_css = true;
+ if (a.name == "version.json") has_version = true;
+ }
+ if (!has_index || !has_bundle_js || !has_bundle_css || !has_version) {
+ fprintf(stderr, "embed: missing required assets (need index.html, bundle.js, bundle.css, version.json); got:\n");
+ for (const auto & a : assets) {
+ fprintf(stderr, " %s\n", a.name.c_str());
+ }
+ return 1;
+ }
+ }
std::string h;
- h += "#pragma once\n\n#include <stddef.h>\n\n";
+ h += "#pragma once\n\n#include <array>\n#include <string>\n\n";
if (n_assets > 0) {
h += "#define LLAMA_UI_HAS_ASSETS 1\n\n";
}
h +=
"struct llama_ui_asset {\n"
- " const char * name;\n"
+ " std::string name;\n"
" const unsigned char * data;\n"
- " size_t size;\n"
- " const char * etag;\n"
+ " std::size_t size;\n"
+ " std::string etag;\n"
+ " std::string type;\n"
"};\n\n"
- "const llama_ui_asset * llama_ui_find_asset(const char * name);\n";
+ "const llama_ui_asset * llama_ui_find_asset(const std::string & name);\n";
+ h += fmt("const std::array<llama_ui_asset, %d> & llama_ui_get_assets();\n", n_assets);
std::string cpp;
- cpp += "#include \"ui.h\"\n\n#include <string.h>\n\n";
+ cpp += "#include \"ui.h\"\n\n";
if (n_assets > 0) {
for (int i = 0; i < n_assets; i++) {
- const char * path = argv[3 + i * 2 + 1];
std::vector<unsigned char> bytes;
- if (!read_file(path, bytes)) {
+ if (!read_file(assets[i].path, bytes)) {
return 1;
}
cpp += fmt("static const unsigned char asset_%d_data[] = {", i);
append_bytes_hex(cpp, bytes);
const auto hash = fnv_hash(bytes.data(), bytes.size());
- cpp += fmt("};\nstatic const size_t asset_%d_size = %zu;\n",
+ cpp += fmt("};\nstatic const std::size_t asset_%d_size = %zu;\n",
i, bytes.size());
- cpp += fmt("static const char asset_%d_etag[] = \"\\\"0x%016" PRIx64 "\\\"\";\n\n",
+ cpp += fmt("static const char asset_%d_etag[] = \"\\\"0x%016" PRIx64 "\\\"\";\n\n",
i, hash);
}
- cpp += "static const llama_ui_asset g_assets[] = {\n";
+ cpp += fmt("static const std::array<llama_ui_asset, %d> g_assets = {{\n", n_assets);
for (int i = 0; i < n_assets; i++) {
- cpp += fmt(" { \"%s\", asset_%d_data, asset_%d_size, asset_%d_etag },\n",
- argv[3 + i * 2], i, i, i);
+ const std::string & name = assets[i].name;
+ cpp += fmt(" { \"%s\", asset_%d_data, asset_%d_size, asset_%d_etag, \"%s\" },\n",
+ name.c_str(), i, i, i, mime_from_ext(name));
}
- cpp += "};\n\n";
+ cpp += "}};\n\n";
cpp +=
- "const llama_ui_asset * llama_ui_find_asset(const char * name) {\n"
+ "const llama_ui_asset * llama_ui_find_asset(const std::string & name) {\n"
" for (const auto & a : g_assets) {\n"
- " if (strcmp(a.name, name) == 0) {\n"
+ " if (a.name == name) {\n"
" return &a;\n"
" }\n"
" }\n"
" return nullptr;\n"
"}\n";
+ cpp += fmt("const std::array<llama_ui_asset, %d> & llama_ui_get_assets() {\n", n_assets);
+ cpp += " return g_assets;\n"
+ "}\n";
} else {
cpp +=
- "const llama_ui_asset * llama_ui_find_asset(const char *) {\n"
+ "const llama_ui_asset * llama_ui_find_asset(const std::string &) {\n"
" return nullptr;\n"
+ "}\n"
+ "const std::array<llama_ui_asset, 0> & llama_ui_get_assets() {\n"
+ " static const std::array<llama_ui_asset, 0> empty{};\n"
+ " return empty;\n"
"}\n";
}