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