forked from
tokono.ma/diffuse
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 // 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}