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()
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()
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()
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()
# ---------------------------------------------------------------------------
# 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()
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)
# 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. "
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"
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;
}
};
};
- // 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;
static const std::unordered_set<std::string> 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));
// Usage:
// llama-ui-embed <out_cpp> <out_h> [<asset_dir>]
//
-// Embeds every regular file directly under <asset_dir> (non-recursive).
+// Recursively embeds every regular file under <asset_dir>.
+// Asset names are relative paths from <asset_dir> (e.g. "_app/immutable/bundle.HASH.js").
// Without <asset_dir>, emits an empty asset table.
#include <inttypes.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
+#include <functional>
#include <string>
#include <vector>
if (!content.empty()) {
out.write(content.data(), static_cast<std::streamsize>(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, ...) {
const std::string out_cpp = argv[1];
const std::string out_h = argv[2];
+ const std::string in_dir = argv[3];
std::vector<asset_entry> 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;
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
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;
+ using match_fn = std::function<bool(const std::string &)>;
+ 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;
+ const std::string base = path_basename(a.name);
+ for (auto & c : checks) {
+ if (!c.found) { c.found = c.match(base); }
+ }
+ }
+
+ std::vector<const char *> missing;
+ for (const auto & c : checks) {
+ if (!c.found) { missing.push_back(c.label); }
}
- 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");
+ if (!missing.empty()) {
+ fprintf(stderr, "\ncurrent asset files:\n");
for (const auto & a : assets) {
- fprintf(stderr, " %s\n", a.name.c_str());
+ 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;
}
}
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());
"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",
+++ /dev/null
-#!/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');
-}