use js_sys::Reflect;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceWorkerOptions {
pub gguf_path_prefix: String,
pub cache_name: String,
}
impl Default for ServiceWorkerOptions {
fn default() -> Self {
Self {
gguf_path_prefix: "/models/".to_string(),
cache_name: "oxillama-model-cache-v1".to_string(),
}
}
}
#[wasm_bindgen(js_name = "getServiceWorkerScript")]
pub fn get_service_worker_script(options_json: &str) -> Result<String, JsValue> {
let opts: ServiceWorkerOptions = serde_json::from_str(options_json)
.map_err(|e| JsValue::from_str(&format!("ServiceWorkerOptions parse error: {e}")))?;
Ok(generate_service_worker_script(&opts))
}
fn generate_service_worker_script(opts: &ServiceWorkerOptions) -> String {
format!(
r#"// OxiLLaMa GGUF model cache service worker
// Auto-generated by oxillama-wasm getServiceWorkerScript().
// DO NOT EDIT — regenerate via the Rust WASM bindings.
const CACHE_NAME = '{cache_name}';
const GGUF_PREFIX = '{prefix}';
// ── Install ───────────────────────────────────────────────────────────────────
// Skip the waiting phase so the new worker activates immediately,
// without waiting for all existing tabs to close.
self.addEventListener('install', (event) => {{
self.skipWaiting();
}});
// ── Activate ─────────────────────────────────────────────────────────────────
// Claim all existing clients immediately so that the current page is
// controlled by this service worker on first activation.
self.addEventListener('activate', (event) => {{
event.waitUntil(clients.claim());
}});
// ── Fetch ─────────────────────────────────────────────────────────────────────
// Intercept requests whose pathname starts with GGUF_PREFIX and serve
// them from the Cache Storage API when available.
self.addEventListener('fetch', (event) => {{
const url = new URL(event.request.url);
if (url.pathname.startsWith(GGUF_PREFIX)) {{
event.respondWith(handleGgufFetch(event.request));
}}
// Non-GGUF requests fall through to the browser default handler.
}});
// ── GGUF fetch handler ────────────────────────────────────────────────────────
// Cache-first strategy: return cached response if available; otherwise
// fetch from network, cache the successful response, then return it.
async function handleGgufFetch(request) {{
const cache = await caches.open(CACHE_NAME);
// 1. Try cache first.
const cached = await cache.match(request);
if (cached) {{
return cached;
}}
// 2. Cache miss — fetch from network.
let response;
try {{
response = await fetch(request);
}} catch (networkError) {{
// Network failure with no cached fallback: propagate the error.
throw networkError;
}}
// 3. Cache the response only when the server returned a successful status
// (2xx). Do not cache error responses (4xx / 5xx) or opaque redirects.
if (response.ok) {{
// Clone before consuming: the original is returned to the browser,
// the clone is stored in the cache.
cache.put(request, response.clone());
}}
return response;
}}
"#,
cache_name = opts.cache_name,
prefix = opts.gguf_path_prefix,
)
}
#[wasm_bindgen(js_name = "registerServiceWorker")]
pub fn register_service_worker(script_url: &str) -> js_sys::Promise {
let global = js_sys::global();
let navigator = match Reflect::get(&global, &JsValue::from_str("navigator")) {
Ok(val) if !val.is_undefined() && !val.is_null() => val,
_ => {
return js_sys::Promise::reject(&JsValue::from_str(
"navigator is not available in this context",
));
}
};
let sw_container = match Reflect::get(&navigator, &JsValue::from_str("serviceWorker")) {
Ok(val) if !val.is_undefined() && !val.is_null() => val,
_ => {
return js_sys::Promise::reject(&JsValue::from_str(
"navigator.serviceWorker is not available — \
service workers require HTTPS (or localhost)",
));
}
};
let register_fn_val = match Reflect::get(&sw_container, &JsValue::from_str("register")) {
Ok(val) if val.is_function() => val,
_ => {
return js_sys::Promise::reject(&JsValue::from_str(
"navigator.serviceWorker.register is not a function",
));
}
};
let register_fn = js_sys::Function::from(register_fn_val);
let args = js_sys::Array::new();
args.push(&JsValue::from_str(script_url));
match register_fn.apply(&sw_container, &args) {
Ok(promise_val) => {
js_sys::Promise::from(promise_val)
}
Err(e) => js_sys::Promise::reject(&JsValue::from_str(&format!(
"serviceWorker.register() threw: {e:?}"
))),
}
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use super::*;
#[test]
fn service_worker_options_serde_roundtrip() {
let original = ServiceWorkerOptions {
gguf_path_prefix: "/assets/models/".to_string(),
cache_name: "my-model-cache-v3".to_string(),
};
let json =
serde_json::to_string(&original).expect("ServiceWorkerOptions must serialize to JSON");
let restored: ServiceWorkerOptions = serde_json::from_str(&json)
.expect("JSON must deserialize back to ServiceWorkerOptions");
assert_eq!(
restored.gguf_path_prefix, original.gguf_path_prefix,
"gguf_path_prefix must survive JSON round-trip"
);
assert_eq!(
restored.cache_name, original.cache_name,
"cache_name must survive JSON round-trip"
);
}
#[test]
fn service_worker_default_cache_name() {
let opts = ServiceWorkerOptions::default();
assert_eq!(
opts.cache_name, "oxillama-model-cache-v1",
"default cache_name must be 'oxillama-model-cache-v1'"
);
}
#[test]
fn generated_script_contains_expected_identifiers() {
let opts = ServiceWorkerOptions {
gguf_path_prefix: "/models/".to_string(),
cache_name: "test-cache-v1".to_string(),
};
let script = generate_service_worker_script(&opts);
assert!(
script.contains("test-cache-v1"),
"cache name must appear in generated script"
);
assert!(
script.contains("/models/"),
"GGUF prefix must appear in generated script"
);
assert!(
script.contains("handleGgufFetch"),
"fetch handler must be present"
);
assert!(
script.contains("skipWaiting"),
"install handler must call skipWaiting"
);
assert!(
script.contains("clients.claim"),
"activate handler must call clients.claim"
);
}
#[test]
fn service_worker_options_rejects_invalid_json() {
let result: Result<ServiceWorkerOptions, _> = serde_json::from_str("{not valid json}");
assert!(
result.is_err(),
"invalid JSON must fail to deserialize into ServiceWorkerOptions"
);
}
#[test]
fn service_worker_options_accepts_valid_json() {
let json = r#"{"gguf_path_prefix": "/llm/", "cache_name": "llm-v2"}"#;
let opts: ServiceWorkerOptions =
serde_json::from_str(json).expect("valid JSON must deserialize");
let script = generate_service_worker_script(&opts);
assert!(script.contains("/llm/"));
assert!(script.contains("llm-v2"));
}
}