A music player that connects to your cloud/distributed storage.
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}