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

Configure Feed

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

at 5874e38496d049324e05411e27558437d00a1a7b 208 lines 6.2 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 // Only intercept GET requests over http(s). 55 if (request.method !== "GET") return; 56 if (!request.url.startsWith("http")) return; 57 58 event.respondWith(handleFetch(request)); 59}); 60 61//////////////////////////////////////////// 62// CONTENT-ADDRESSED CACHE 63//////////////////////////////////////////// 64 65/** 66 * @param {string} cid 67 */ 68function cidUrl(cid) { 69 return `https://diffuse.offline.worker/${cid}`; 70} 71 72/** 73 * Opens the two caches used for content-addressed storage. 74 * 75 * - `<name>:index` maps original request URL → CID string (text/plain) 76 * - `<name>:content` maps `https://diffuse.offline.worker/<cid>` → full response (deduplicated) 77 */ 78async function openCaches() { 79 const [index, content] = await Promise.all([ 80 caches.open(CACHE_NAME + ":index"), 81 caches.open(CACHE_NAME + ":content"), 82 ]); 83 return { index, content }; 84} 85 86/** 87 * Looks up a pathname in the pre-built file tree and returns its CID, or 88 * `undefined` if the entry is absent or the tree failed to load. 89 * 90 * @param {string} pathname - e.g. "/components/foo.js" 91 * @returns {Promise<string | undefined>} 92 */ 93async function cidFromTree(pathname) { 94 /** @type {Record<string, string> | null} */ 95 const tree = await fileTreePromise; 96 if (!tree) return undefined; 97 const key = pathname.replace(/^\//, ""); 98 return tree[key]; 99} 100 101/** 102 * Computes the CID of `response`'s body and writes it into the two-level cache. 103 * The same content is stored only once, regardless of how many URLs reference it. 104 * 105 * Uses the pre-built file tree CID when available; falls back to hashing the 106 * response body when the entry is missing from the tree. 107 * 108 * @param {Request} request 109 * @param {Response} response - a clone; its body is fully consumed here 110 */ 111async function store(request, response) { 112 const { pathname } = new URL(request.url); 113 const cid = await cidFromTree(pathname) ?? 114 await createCid( 115 RAW_CODEC, 116 new Uint8Array(await response.clone().arrayBuffer()), 117 ); 118 const cidKey = cidUrl(cid); 119 120 const caches = await openCaches(); 121 122 // Only store the content if we haven't seen this CID before 123 if (!(await caches.content.match(cidKey))) { 124 await caches.content.put(new Request(cidKey), response); 125 } 126 127 // Update the URL → CID map 128 await caches.index.put( 129 new Request(request.url), 130 new Response(cid, { headers: { "content-type": "text/plain" } }), 131 ); 132} 133 134/** 135 * Resolves the cached response for a request via the URL → CID index. 136 * 137 * @param {Request} request 138 * @returns {Promise<Response | undefined>} 139 */ 140async function lookup(request) { 141 const caches = await openCaches(); 142 143 const indexEntry = await caches.index.match(request); 144 if (!indexEntry) return undefined; 145 146 const cid = await indexEntry.text(); 147 return caches.content.match(cidUrl(cid)); 148} 149 150//////////////////////////////////////////// 151// HANDLER 152//////////////////////////////////////////// 153 154/** 155 * Cache-first for file-tree entries, network-first for everything else. 156 * 157 * Online + in file tree → serve from cache if present, otherwise fetch and store. 158 * Online + not in tree → fetch from network, store response by CID, return it. 159 * Offline → resolve the URL through the index, serve by CID from the content cache. 160 * 161 * Partial responses (206) are passed through without caching so that 162 * range requests for audio streaming work as normal. 163 * 164 * @param {Request} request 165 * @returns {Promise<Response>} 166 */ 167async function handleFetch(request) { 168 if (navigator.onLine) { 169 const { pathname } = new URL(request.url); 170 if (await cidFromTree(pathname) !== undefined) { 171 const cached = await lookup(request); 172 if (cached) return cached; 173 } 174 175 try { 176 return await fetchAndStore(request); 177 } catch {} 178 } 179 180 const cached = await lookup(request); 181 if (cached) return cached; 182 183 return new Response(null, { 184 status: 503, 185 statusText: "Unavailable asset, not cached", 186 }); 187} 188 189/** 190 * @param {Request} request 191 */ 192async function fetchAndStore(request) { 193 const response = await fetch(request); 194 195 // Partial content (range requests) — return as-is, do not cache. 196 if (response.status === 206) return response; 197 198 // Skip caching audio/video. 199 const contentType = response.headers.get("content-type") ?? ""; 200 if (MEDIA_CONTENT_TYPE.test(contentType)) return response; 201 202 // Cache full successful responses, including opaque cross-origin ones. 203 if (response.status === 200 || response.type === "opaque") { 204 store(request, response.clone()).catch(() => {}); 205 } 206 207 return response; 208}