Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
316 lines
7.8 KiB
JavaScript
316 lines
7.8 KiB
JavaScript
/**
|
|
* OpenReel Service Worker
|
|
*
|
|
* Handles offline functionality by caching application assets.
|
|
* Implements a cache-first strategy for static assets and network-first for API calls.
|
|
*
|
|
* Requirements: 35.1, 35.2, 35.4
|
|
* - 35.1: Cache all application assets on first load for offline use
|
|
* - 35.2: Function fully for all non-AI features when offline
|
|
* - 35.4: Inform user that AI requires internet connectivity
|
|
*/
|
|
|
|
const CACHE_NAME = "openreel-v1";
|
|
const STATIC_CACHE_NAME = "openreel-static-v1";
|
|
const DYNAMIC_CACHE_NAME = "openreel-dynamic-v1";
|
|
|
|
/**
|
|
* Static assets to cache on install
|
|
* These are the core application files needed for offline functionality
|
|
*/
|
|
const STATIC_ASSETS = ["/", "/index.html", "/manifest.json"];
|
|
|
|
/**
|
|
* Patterns for assets that should be cached dynamically
|
|
*/
|
|
const CACHEABLE_PATTERNS = [
|
|
/\.js$/,
|
|
/\.css$/,
|
|
/\.woff2?$/,
|
|
/\.ttf$/,
|
|
/\.eot$/,
|
|
/\.svg$/,
|
|
/\.png$/,
|
|
/\.jpg$/,
|
|
/\.jpeg$/,
|
|
/\.gif$/,
|
|
/\.webp$/,
|
|
/\.ico$/,
|
|
];
|
|
|
|
/**
|
|
* Patterns for requests that should never be cached (AI features, etc.)
|
|
*/
|
|
const NO_CACHE_PATTERNS = [
|
|
/api\.anthropic\.com/,
|
|
/api\.openai\.com/,
|
|
/whisper/,
|
|
/transcribe/,
|
|
/\/api\//,
|
|
];
|
|
|
|
/**
|
|
* Check if a URL should be cached
|
|
*/
|
|
function shouldCache(url) {
|
|
const urlString = url.toString();
|
|
|
|
// Never cache AI-related requests
|
|
if (NO_CACHE_PATTERNS.some((pattern) => pattern.test(urlString))) {
|
|
return false;
|
|
}
|
|
|
|
// Cache if matches cacheable patterns
|
|
return CACHEABLE_PATTERNS.some((pattern) => pattern.test(urlString));
|
|
}
|
|
|
|
/**
|
|
* Check if a request is for an AI feature
|
|
*/
|
|
function isAIRequest(url) {
|
|
const urlString = url.toString();
|
|
return NO_CACHE_PATTERNS.some((pattern) => pattern.test(urlString));
|
|
}
|
|
|
|
/**
|
|
* Install event - cache static assets
|
|
*/
|
|
self.addEventListener("install", (event) => {
|
|
console.log("[ServiceWorker] Installing...");
|
|
|
|
event.waitUntil(
|
|
caches
|
|
.open(STATIC_CACHE_NAME)
|
|
.then((cache) => {
|
|
console.log("[ServiceWorker] Caching static assets");
|
|
return cache.addAll(STATIC_ASSETS);
|
|
})
|
|
.then(() => {
|
|
console.log("[ServiceWorker] Static assets cached");
|
|
// Skip waiting to activate immediately
|
|
return self.skipWaiting();
|
|
})
|
|
.catch((error) => {
|
|
console.error("[ServiceWorker] Failed to cache static assets:", error);
|
|
})
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Activate event - clean up old caches
|
|
*/
|
|
self.addEventListener("activate", (event) => {
|
|
console.log("[ServiceWorker] Activating...");
|
|
|
|
event.waitUntil(
|
|
caches
|
|
.keys()
|
|
.then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames
|
|
.filter((name) => {
|
|
// Delete old versions of our caches
|
|
return (
|
|
name.startsWith("openreel-") &&
|
|
name !== STATIC_CACHE_NAME &&
|
|
name !== DYNAMIC_CACHE_NAME
|
|
);
|
|
})
|
|
.map((name) => {
|
|
console.log("[ServiceWorker] Deleting old cache:", name);
|
|
return caches.delete(name);
|
|
})
|
|
);
|
|
})
|
|
.then(() => {
|
|
console.log("[ServiceWorker] Activated");
|
|
// Take control of all clients immediately
|
|
return self.clients.claim();
|
|
})
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Fetch event - serve from cache or network
|
|
*/
|
|
self.addEventListener("fetch", (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// Skip non-GET requests
|
|
if (request.method !== "GET") {
|
|
return;
|
|
}
|
|
|
|
// Skip chrome-extension and other non-http(s) requests
|
|
if (!url.protocol.startsWith("http")) {
|
|
return;
|
|
}
|
|
|
|
// Handle AI requests - network only with offline message
|
|
if (isAIRequest(url)) {
|
|
event.respondWith(
|
|
fetch(request).catch(() => {
|
|
// Return a JSON response indicating AI is unavailable offline
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: "AI_OFFLINE",
|
|
message:
|
|
"AI features require an internet connection. Please connect to the internet to use this feature.",
|
|
}),
|
|
{
|
|
status: 503,
|
|
statusText: "Service Unavailable",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
}
|
|
);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// For navigation requests (HTML pages), use network-first strategy
|
|
if (request.mode === "navigate") {
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then((response) => {
|
|
// Cache the response for offline use
|
|
const responseClone = response.clone();
|
|
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
|
|
cache.put(request, responseClone);
|
|
});
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
// Fall back to cache
|
|
return caches.match(request).then((cachedResponse) => {
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
// Fall back to index.html for SPA routing
|
|
return caches.match("/index.html");
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// For static assets, use cache-first strategy
|
|
if (shouldCache(url)) {
|
|
event.respondWith(
|
|
caches.match(request).then((cachedResponse) => {
|
|
if (cachedResponse) {
|
|
// Return cached response and update cache in background
|
|
event.waitUntil(
|
|
fetch(request)
|
|
.then((networkResponse) => {
|
|
if (networkResponse.ok) {
|
|
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
|
|
cache.put(request, networkResponse);
|
|
});
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Network failed, but we have cache - that's fine
|
|
})
|
|
);
|
|
return cachedResponse;
|
|
}
|
|
|
|
// Not in cache, fetch from network
|
|
return fetch(request).then((networkResponse) => {
|
|
if (networkResponse.ok) {
|
|
const responseClone = networkResponse.clone();
|
|
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
|
|
cache.put(request, responseClone);
|
|
});
|
|
}
|
|
return networkResponse;
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// For other requests, use network-first strategy
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then((response) => {
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
return caches.match(request);
|
|
})
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Message event - handle messages from the main thread
|
|
*/
|
|
self.addEventListener("message", (event) => {
|
|
const { type, payload } = event.data || {};
|
|
|
|
switch (type) {
|
|
case "SKIP_WAITING":
|
|
self.skipWaiting();
|
|
break;
|
|
|
|
case "GET_CACHE_STATUS":
|
|
getCacheStatus().then((status) => {
|
|
event.ports[0].postMessage({ type: "CACHE_STATUS", payload: status });
|
|
});
|
|
break;
|
|
|
|
case "CLEAR_CACHE":
|
|
clearAllCaches().then(() => {
|
|
event.ports[0].postMessage({ type: "CACHE_CLEARED" });
|
|
});
|
|
break;
|
|
|
|
case "CHECK_ONLINE":
|
|
event.ports[0].postMessage({
|
|
type: "ONLINE_STATUS",
|
|
payload: { online: navigator.onLine },
|
|
});
|
|
break;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get cache status information
|
|
*/
|
|
async function getCacheStatus() {
|
|
const cacheNames = await caches.keys();
|
|
let totalSize = 0;
|
|
let totalEntries = 0;
|
|
|
|
for (const name of cacheNames) {
|
|
if (name.startsWith("openreel-")) {
|
|
const cache = await caches.open(name);
|
|
const keys = await cache.keys();
|
|
totalEntries += keys.length;
|
|
}
|
|
}
|
|
|
|
return {
|
|
cacheNames: cacheNames.filter((n) => n.startsWith("openreel-")),
|
|
totalEntries,
|
|
version: CACHE_NAME,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clear all OpenReel caches
|
|
*/
|
|
async function clearAllCaches() {
|
|
const cacheNames = await caches.keys();
|
|
await Promise.all(
|
|
cacheNames
|
|
.filter((name) => name.startsWith("openreel-"))
|
|
.map((name) => caches.delete(name))
|
|
);
|
|
}
|
|
|
|
console.log("[ServiceWorker] Script loaded");
|