A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v4 251 lines 7.8 kB view raw
1/// <reference lib="webworker" /> 2 3import { create as createCid } from "./common/cid.js"; 4 5const fileTreePromise = import("./file-tree.json", { with: { type: "json" } }) 6 .then((m) => m.default) 7 .catch(() => null); 8 9/** Media content types to ignore */ 10const MEDIA_CONTENT_TYPE = /^(audio|video)\//; 11 12/** Multicodec code for raw binary content. */ 13const RAW_CODEC = 0x55; 14 15const { searchParams } = new URL(location.href); 16const CACHE_NAME = searchParams.get("cache-name") ?? "diffuse-offline"; 17 18const thyself = 19 /** @type {ServiceWorkerGlobalScope & typeof globalThis} */ (/** @type {unknown} */ (self)); 20 21//////////////////////////////////////////// 22// INSTALL 23//////////////////////////////////////////// 24 25self.addEventListener("install", (_event) => { 26 // Activate immediately without waiting for existing clients to close. 27 /** @type {ExtendableEvent} */ (_event).waitUntil(thyself.skipWaiting()); 28}); 29 30//////////////////////////////////////////// 31// ACTIVATE 32//////////////////////////////////////////// 33 34self.addEventListener("activate", (event) => { 35 // Take control of all open clients right away, then reload them so every 36 // page starts fresh under the new service worker with no mid-session split. 37 /** @type {ExtendableEvent} */ (event).waitUntil( 38 thyself.clients.claim().then(() => 39 thyself.clients.matchAll({ type: "window" }).then((clients) => { 40 for (const client of clients) client.navigate(client.url); 41 }) 42 ) 43 ); 44}); 45 46//////////////////////////////////////////// 47// FETCH 48//////////////////////////////////////////// 49 50self.addEventListener("fetch", (_event) => { 51 const event = /** @type {FetchEvent} */ (_event); 52 const { request } = event; 53 54 if (!request.url.startsWith("http")) return; 55 56 // Intercept credentialed URLs before the offline cache (any method). 57 const intercepted = interceptCredentials(request); 58 if (intercepted) { 59 event.respondWith(intercepted); 60 return; 61 } 62 63 // Only cache GET requests. 64 if (request.method !== "GET") return; 65 66 event.respondWith(handleFetch(request)); 67}); 68 69//////////////////////////////////////////// 70// CREDENTIAL INTERCEPT 71//////////////////////////////////////////// 72 73/** 74 * If the request URL contains a `_auth` query parameter (base64 Basic credentials), 75 * strip it and re-issue the request with a proper Authorization header instead. 76 * Also handles the legacy `user:pass@host` form for any callers that still use it. 77 * 78 * Returns a Promise<Response> when credentials are detected, or null to fall through. 79 * 80 * @param {Request} request 81 * @returns {Promise<Response> | null} 82 */ 83function interceptCredentials(request) { 84 const url = new URL(request.url); 85 86 if (url.username) { 87 const credentials = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`; 88 url.username = ""; 89 url.password = ""; 90 const headers = new Headers(request.headers); 91 headers.set("Authorization", `Basic ${btoa(unescape(encodeURIComponent(credentials)))}`); 92 return fetch(url.href, { method: request.method, headers, signal: request.signal }); 93 } 94 95 const auth = url.searchParams.get("diffuse:basic-auth"); 96 if (!auth) return null; 97 98 url.searchParams.delete("diffuse:basic-auth"); 99 const headers = new Headers(request.headers); 100 headers.set("Authorization", `Basic ${auth}`); 101 return fetch(url.href, { method: request.method, headers, signal: request.signal }); 102} 103 104//////////////////////////////////////////// 105// CONTENT-ADDRESSED CACHE 106//////////////////////////////////////////// 107 108/** 109 * @param {string} cid 110 */ 111function cidUrl(cid) { 112 return `https://diffuse.offline.worker/${cid}`; 113} 114 115/** 116 * Opens the two caches used for content-addressed storage. 117 * 118 * - `<name>:index` maps original request URL → CID string (text/plain) 119 * - `<name>:content` maps `https://diffuse.offline.worker/<cid>` → full response (deduplicated) 120 */ 121async function openCaches() { 122 const [index, content] = await Promise.all([ 123 caches.open(CACHE_NAME + ":index"), 124 caches.open(CACHE_NAME + ":content"), 125 ]); 126 return { index, content }; 127} 128 129/** 130 * Looks up a pathname in the pre-built file tree and returns its CID, or 131 * `undefined` if the entry is absent or the tree failed to load. 132 * 133 * @param {string} pathname - e.g. "/components/foo.js" 134 * @returns {Promise<string | undefined>} 135 */ 136async function cidFromTree(pathname) { 137 /** @type {Record<string, string> | null} */ 138 const tree = await fileTreePromise; 139 if (!tree) return undefined; 140 const key = pathname.replace(/^\//, ""); 141 return tree[key]; 142} 143 144/** 145 * Computes the CID of `response`'s body and writes it into the two-level cache. 146 * The same content is stored only once, regardless of how many URLs reference it. 147 * 148 * Uses the pre-built file tree CID when available; falls back to hashing the 149 * response body when the entry is missing from the tree. 150 * 151 * @param {Request} request 152 * @param {Response} response - a clone; its body is fully consumed here 153 */ 154async function store(request, response) { 155 const { pathname } = new URL(request.url); 156 const cid = await cidFromTree(pathname) ?? 157 await createCid( 158 RAW_CODEC, 159 new Uint8Array(await response.clone().arrayBuffer()), 160 ); 161 const cidKey = cidUrl(cid); 162 163 const caches = await openCaches(); 164 165 // Only store the content if we haven't seen this CID before 166 if (!(await caches.content.match(cidKey))) { 167 await caches.content.put(new Request(cidKey), response); 168 } 169 170 // Update the URL → CID map 171 await caches.index.put( 172 new Request(request.url), 173 new Response(cid, { headers: { "content-type": "text/plain" } }), 174 ); 175} 176 177/** 178 * Resolves the cached response for a request via the URL → CID index. 179 * 180 * @param {Request} request 181 * @returns {Promise<Response | undefined>} 182 */ 183async function lookup(request) { 184 const caches = await openCaches(); 185 186 const indexEntry = await caches.index.match(request); 187 if (!indexEntry) return undefined; 188 189 const cid = await indexEntry.text(); 190 return caches.content.match(cidUrl(cid)); 191} 192 193//////////////////////////////////////////// 194// HANDLER 195//////////////////////////////////////////// 196 197/** 198 * Cache-first for file-tree entries, network-first for everything else. 199 * 200 * Online + in file tree → serve from cache if present, otherwise fetch and store. 201 * Online + not in tree → fetch from network, store response by CID, return it. 202 * Offline → resolve the URL through the index, serve by CID from the content cache. 203 * 204 * Partial responses (206) are passed through without caching so that 205 * range requests for audio streaming work as normal. 206 * 207 * @param {Request} request 208 * @returns {Promise<Response>} 209 */ 210async function handleFetch(request) { 211 if (navigator.onLine) { 212 const { pathname } = new URL(request.url); 213 if (await cidFromTree(pathname) !== undefined) { 214 const cached = await lookup(request); 215 if (cached) return cached; 216 } 217 218 try { 219 return await fetchAndStore(request); 220 } catch {} 221 } 222 223 const cached = await lookup(request); 224 if (cached) return cached; 225 226 return new Response(null, { 227 status: 503, 228 statusText: "Unavailable asset, not cached", 229 }); 230} 231 232/** 233 * @param {Request} request 234 */ 235async function fetchAndStore(request) { 236 const response = await fetch(request); 237 238 // Partial content (range requests) — return as-is, do not cache. 239 if (response.status === 206) return response; 240 241 // Skip caching audio/video. 242 const contentType = response.headers.get("content-type") ?? ""; 243 if (MEDIA_CONTENT_TYPE.test(contentType)) return response; 244 245 // Cache full successful responses, including opaque cross-origin ones. 246 if (response.status === 200 || response.type === "opaque") { 247 store(request, response.clone()).catch(() => {}); 248 } 249 250 return response; 251}