diff --git a/scripts/ui-assets.cmake b/scripts/ui-assets.cmake index e5b47863ec..89f22b14ac 100644 --- a/scripts/ui-assets.cmake +++ b/scripts/ui-assets.cmake @@ -17,45 +17,16 @@ set(HF_ENABLED "" CACHE STRING "Whether to allow HF Bucket download (ON/O 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: -# - tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES, PWA_MANIFEST) -# -# The HTTP server registers routes and public endpoints for every embedded asset. -set(REQUIRED_ASSETS - index.html - loading.html - manifest.webmanifest - sw.js - 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(SRC_DIST_DIR "${UI_SOURCE_DIR}/dist") set(STAMP_FILE "${UI_BINARY_DIR}/.ui-stamp") set(UI_CPP "${UI_BINARY_DIR}/ui.cpp") set(UI_H "${UI_BINARY_DIR}/ui.h") -function(assets_present dir out_var) - set(present TRUE) - foreach(asset ${REQUIRED_ASSETS}) - if(NOT EXISTS "${dir}/${asset}") - set(present FALSE) - break() - endif() - endforeach() - set(${out_var} ${present} PARENT_SCOPE) -endfunction() - function(npm_build_should_skip out_var) set(${out_var} FALSE PARENT_SCOPE) - assets_present("${DIST_DIR}" present) - if(NOT present) + if(NOT EXISTS "${DIST_DIR}/index.html") return() endif() @@ -162,8 +133,7 @@ function(npm_build out_var) return() endif() - assets_present("${DIST_DIR}" present) - if(NOT present) + if(NOT EXISTS "${DIST_DIR}/index.html") message(STATUS "UI: npm build finished but assets missing in ${DIST_DIR}") return() endif() @@ -242,8 +212,7 @@ function(hf_download version out_var out_resolved) file(ARCHIVE_EXTRACT INPUT "${archive}" DESTINATION "${DIST_DIR}") - assets_present("${DIST_DIR}" present) - if(NOT present) + if(NOT EXISTS "${DIST_DIR}/index.html") message(STATUS "UI: archive from ${resolved} is missing required assets") continue() endif() @@ -256,11 +225,8 @@ function(hf_download version out_var out_resolved) endfunction() function(emit_files dist_dir) - assets_present("${dist_dir}" present) - set(args "${UI_CPP}" "${UI_H}") - if(present) - # llama-ui-embed embeds every top-level file in dist_dir + if(EXISTS "${dist_dir}/index.html") list(APPEND args "${dist_dir}") endif() @@ -276,8 +242,7 @@ endfunction() # --------------------------------------------------------------------------- # 1. Priority 1: pre-built assets supplied in tools/ui/dist # --------------------------------------------------------------------------- -assets_present("${SRC_DIST_DIR}" SRC_OK) -if(SRC_OK) +if(EXISTS "${SRC_DIST_DIR}/index.html") message(STATUS "UI: using pre-built assets from ${SRC_DIST_DIR}") emit_files("${SRC_DIST_DIR}") return() @@ -312,7 +277,10 @@ if(NOT provisioned AND HF_ENABLED) endif() endif() - assets_present("${DIST_DIR}" have_assets) + set(have_assets FALSE) + if(EXISTS "${DIST_DIR}/index.html") + set(have_assets TRUE) + endif() if(stamp_ok AND have_assets) message(STATUS "UI: HF stamp '${stamped}' matches version, skipping HF fetch") set(provisioned TRUE) @@ -332,8 +300,7 @@ endif() # 4. Fallback: warn about stale or missing assets, then emit whatever we have # --------------------------------------------------------------------------- if(NOT provisioned) - assets_present("${DIST_DIR}" have_assets) - if(have_assets) + if(EXISTS "${DIST_DIR}/index.html") message(WARNING "UI: provisioning failed; embedding stale assets from ${DIST_DIR}") else() message(WARNING "UI: no assets available - building without an embedded UI. " diff --git a/tools/server/server-http.cpp b/tools/server/server-http.cpp index bb63cc5985..e79f0cbab8 100644 --- a/tools/server/server-http.cpp +++ b/tools/server/server-http.cpp @@ -26,52 +26,6 @@ server_http_context::server_http_context() 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" @@ -240,9 +194,8 @@ bool server_http_context::init(const common_params & params) { return true; } - // 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))) { + // If path is public or a UI asset, skip validation + if (get_public_endpoints.count(req.path)) { return true; } @@ -399,17 +352,9 @@ bool server_http_context::init(const common_params & params) { }; }; - // 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")); + // main index file + srv->Get(params.api_prefix + "/", serve_asset_cached("index.html", true)); + srv->Get(params.api_prefix + "/index.html", serve_asset_cached("index.html", true)); // All remaining assets registered directly from the embedded asset table. // PWA revalidation files (sw.js, manifest, version.json) use no-cache; @@ -417,15 +362,14 @@ bool server_http_context::init(const common_params & params) { static const std::unordered_set no_cache_names = { "sw.js", "manifest.webmanifest", - "version.json", + "_app/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)); 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_DBG("serve nocache for %s\n", a.name.c_str()); 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)); diff --git a/tools/ui/embed.cpp b/tools/ui/embed.cpp index 24617e05fc..2ca022208f 100644 --- a/tools/ui/embed.cpp +++ b/tools/ui/embed.cpp @@ -3,7 +3,8 @@ // Usage: // llama-ui-embed [] // -// Embeds every regular file directly under (non-recursive). +// Recursively embeds every regular file under . +// Asset names are relative paths from (e.g. "_app/immutable/bundle.HASH.js"). // Without , emits an empty asset table. #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include @@ -103,7 +105,24 @@ static bool write_if_different(const std::string & path, const std::string & con if (!content.empty()) { out.write(content.data(), static_cast(content.size())); } - return out.good(); + bool ok = out.good(); + if (ok) { + printf("embed: write output file %s\n", path.c_str()); + } + return ok; +} + +static std::string path_basename(const std::string & name) { + const size_t p = name.rfind('/'); + return p == std::string::npos ? name : name.substr(p + 1); +} +static bool str_starts_with(const std::string & s, const char * prefix) { + const size_t n = strlen(prefix); + return s.size() >= n && s.compare(0, n, prefix) == 0; +} +static bool str_ends_with(const std::string & s, const char * suffix) { + const size_t n = strlen(suffix); + return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0; } static std::string fmt(const char * pattern, ...) { @@ -128,13 +147,14 @@ int main(int argc, char ** argv) { const std::string out_cpp = argv[1]; const std::string out_h = argv[2]; + const std::string in_dir = argv[3]; std::vector assets; if (argc == 4) { - const std::filesystem::path dir = argv[3]; + const std::filesystem::path dir = in_dir; std::error_code ec; - std::filesystem::directory_iterator it(dir, ec); + std::filesystem::recursive_directory_iterator it(dir, ec); if (ec) { fprintf(stderr, "embed: cannot iterate %s: %s\n", argv[3], ec.message().c_str()); return 1; @@ -143,7 +163,9 @@ int main(int argc, char ** argv) { if (!entry.is_regular_file()) { continue; } - assets.push_back({ entry.path().filename().generic_string(), entry.path() }); + // name is the relative path from dir, with forward slashes + const std::string name = entry.path().lexically_relative(dir).generic_string(); + assets.push_back({ name, entry.path() }); } // directory iteration order is unspecified; sort for reproducible output @@ -154,18 +176,51 @@ int main(int argc, char ** argv) { const int n_assets = static_cast(assets.size()); if (n_assets > 0) { - bool has_index = false, has_bundle_js = false, has_bundle_css = false, has_version = false; + using match_fn = std::function; + auto exact = [](const char * name) -> match_fn { + return [name](const std::string & base) { return base == name; }; + }; + + struct required_check { const char * label; match_fn match; bool found; }; + required_check checks[] = { + { "index.html", exact("index.html"), false }, + { "loading.html", exact("loading.html"), false }, + { "manifest.webmanifest", exact("manifest.webmanifest"), false }, + { "sw.js", exact("sw.js"), false }, + { "build.json", exact("build.json"), false }, + { "version.json", exact("version.json"), false }, + { "bundle[hash].js", [](const std::string & b) { + return str_starts_with(b, "bundle") && str_ends_with(b, ".js"); + }, false }, + { "bundle[hash].css", [](const std::string & b) { + return str_starts_with(b, "bundle") && str_ends_with(b, ".css"); + }, false }, + { "workbox[hash].js", [](const std::string & b) { + return str_starts_with(b, "workbox") && str_ends_with(b, ".js"); + }, 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()); + const std::string base = path_basename(a.name); + for (auto & c : checks) { + if (!c.found) { c.found = c.match(base); } } + } + + std::vector missing; + for (const auto & c : checks) { + if (!c.found) { missing.push_back(c.label); } + } + if (!missing.empty()) { + fprintf(stderr, "\ncurrent asset files:\n"); + for (const auto & a : assets) { + fprintf(stderr, " %s\n", a.name.c_str()); + } + fprintf(stderr, "missing required asset(s):\n"); + for (const char * m : missing) { + fprintf(stderr, " %s\n", m); + } + fprintf(stderr, "hint: try cleaning your build directory: %s\n", in_dir.c_str()); return 1; } } @@ -195,6 +250,10 @@ int main(int argc, char ** argv) { if (!read_file(assets[i].path, bytes)) { return 1; } + if (bytes.empty()) { + fprintf(stderr, "embed: empty file: %s\n", assets[i].path.generic_string().c_str()); + 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()); diff --git a/tools/ui/package.json b/tools/ui/package.json index 15dca78886..6c750431fe 100644 --- a/tools/ui/package.json +++ b/tools/ui/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "type": "module", "scripts": { - "build": "npm run build-pwa-assets && vite build && node scripts/post-build.js", + "build": "npm run build-pwa-assets && vite build", "build-pwa-assets": "npx @vite-pwa/assets-generator --root . --config pwa-assets.config.ts && npx @vite-pwa/assets-generator --root . --config pwa-assets-dark.config.ts && node scripts/make-icons-circular.js", "dev": "bash scripts/dev.sh", "preview": "vite preview", diff --git a/tools/ui/scripts/post-build.js b/tools/ui/scripts/post-build.js deleted file mode 100644 index f2e72ce891..0000000000 --- a/tools/ui/scripts/post-build.js +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node -// Post-build: copy hashed/nested assets to predictable flat names. -// No file content is modified — the C++ server handles routing hashed URLs -// to the correct stored asset at runtime. -// -// Copies: -// _app/immutable/bundle.HASH.js -> bundle.js -// _app/immutable/assets/bundle.HASH.css -> bundle.css -// workbox-HEXHASH.js -> workbox.js -// _app/version.json -> version.json - -import fs from 'fs'; -import path from 'path'; - -const outDir = process.env.LLAMA_UI_OUT_DIR ?? './dist'; - -function findOne(dir, pattern) { - const files = fs.readdirSync(dir).filter((f) => pattern.test(f)); - if (files.length === 0) throw new Error(`post-build: no file matching ${pattern} in ${dir}`); - return path.join(dir, files[0]); -} - -function copyFlat(src, destName) { - const dest = path.join(outDir, destName); - fs.copyFileSync(src, dest); - console.log(`post-build: ${path.relative(outDir, src)} -> ${destName}`); -} - -const bundleJs = findOne(path.join(outDir, '_app/immutable'), /^bundle\.[^.]+\.js$/); -const bundleCss = findOne(path.join(outDir, '_app/immutable/assets'), /^bundle\.[^.]+\.css$/); -const workbox = findOne(outDir, /^workbox-[0-9a-f]+\.js$/); - -copyFlat(bundleJs, 'bundle.js'); -copyFlat(bundleCss, 'bundle.css'); -copyFlat(workbox, 'workbox.js'); - -const versionSrc = path.join(outDir, '_app/version.json'); -if (fs.existsSync(versionSrc)) { - copyFlat(versionSrc, 'version.json'); -}