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", () => {
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}