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

fix: service worker and precalculate cids

+80 -25
+37
_config.ts
··· 13 13 import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; 14 14 import { wasmLoader } from "esbuild-plugin-wasm"; 15 15 16 + import { create as createCID } from "~/common/cid.js"; 17 + 16 18 const site = lume({ 17 19 dest: "./dist", 18 20 src: "./src", ··· 40 42 format: "esm", 41 43 minify: false, 42 44 // outExtension: { ".js": ".min.js" }, 45 + external: ["./file-tree.json"], 43 46 platform: "browser", 44 47 plugins: [ 45 48 dotenvRun({ ··· 348 351 // leave as-is if the source file can't be read 349 352 } 350 353 } 354 + }); 355 + 356 + site.addEventListener("afterBuild", async () => { 357 + const RAW = 0x55; 358 + 359 + async function buildFileTree( 360 + dir: string, 361 + prefix = "", 362 + ): Promise<Record<string, string>> { 363 + const tree: Record<string, string> = {}; 364 + 365 + for (const entry of Deno.readDirSync(dir)) { 366 + const entryPath = path.join(dir, entry.name); 367 + const entryKey = prefix ? `${prefix}/${entry.name}` : entry.name; 368 + if (entry.isDirectory) { 369 + Object.assign(tree, await buildFileTree(entryPath, entryKey)); 370 + } else { 371 + const data = Deno.readFileSync(entryPath); 372 + tree[entryKey] = await createCID(RAW, data); 373 + } 374 + } 375 + 376 + return tree; 377 + } 378 + 379 + const tree = await buildFileTree("dist/"); 380 + const sorted = Object.fromEntries( 381 + Object.keys(tree).sort().map((k) => [k, tree[k]]), 382 + ); 383 + 384 + Deno.writeTextFileSync( 385 + "./dist/file-tree.json", 386 + JSON.stringify(sorted, null, 2), 387 + ); 351 388 }); 352 389 353 390 function htmlWithInlineJs({ content, match, jsPath }: {
+7 -9
deno.jsonc
··· 43 43 "subsonic-api": "npm:subsonic-api@^3.2.0", 44 44 "throttle-debounce": "npm:throttle-debounce@^5.0.2", 45 45 "xxh32": "npm:xxh32@^2.0.5", 46 + "webamp": "npm:webamp@^2.2.0", 46 47 47 48 // music-metadata 48 49 "@tokenizer/http": "npm:@tokenizer/http@^0.9.2", 49 50 "@tokenizer/range": "npm:@tokenizer/range@^0.13.0", 50 51 "music-metadata": "npm:music-metadata@^11.10.6", 51 - 52 - // Webamp 53 - "webamp": "npm:webamp@^2.2.0", 54 - 55 - // Paths 56 - "@testing/": "./src/testing/", 57 - "@tests/": "./tests/", 58 - 59 - "~/": "./src/", 60 52 61 53 // Build 62 54 "@atcute/lex-cli": "npm:@atcute/lex-cli@^2.5.3", ··· 73 65 "@astral/astral": "jsr:@astral/astral", 74 66 "@std/expect": "jsr:@std/expect", 75 67 "@std/testing/bdd": "jsr:@std/testing/bdd", 68 + 69 + // Paths 70 + "~/": "./src/", 71 + 72 + "@testing/": "./src/testing/", 73 + "@tests/": "./tests/", 76 74 }, 77 75 "exports": { 78 76 // .js
+1 -1
src/components/orchestrator/offline/element.js
··· 7 7 /** 8 8 * Registers a service worker that makes the page available offline. 9 9 * 10 - * All resources fetched by the page are cached as they load. 10 + * All resources, except audio & video, fetched by the page are cached as they load. 11 11 * While online, requests always go to the network (the cache is bypassed), 12 12 * and successful responses are stored for later. While offline, the cache 13 13 * is used as a fallback.
+1
src/file-tree.json
··· 1 + {}
+34 -15
src/service-worker-offline.js
··· 2 2 3 3 import { create as createCid } from "./common/cid.js"; 4 4 5 + const fileTreePromise = import("./file-tree.json", { with: { type: "json" } }) 6 + .then((m) => m.default) 7 + .catch(() => null); 8 + 5 9 /** Media content types to ignore */ 6 10 const MEDIA_CONTENT_TYPE = /^(audio|video)\//; 7 11 ··· 48 52 }); 49 53 50 54 //////////////////////////////////////////// 51 - // CACHE (content-addressed) 55 + // CONTENT-ADDRESSED CACHE 52 56 //////////////////////////////////////////// 53 57 54 58 /** ··· 73 77 } 74 78 75 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 + */ 86 + async 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 + /** 76 95 * Computes the CID of `response`'s body and writes it into the two-level cache. 77 96 * The same content is stored only once, regardless of how many URLs reference it. 78 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 + * 79 101 * @param {Request} request 80 102 * @param {Response} response - a clone; its body is fully consumed here 81 103 */ 82 104 async function store(request, response) { 83 - const bytes = new Uint8Array(await response.clone().arrayBuffer()); 84 - const cid = await createCid(RAW_CODEC, bytes); 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 + ); 85 111 const cidKey = cidUrl(cid); 86 112 87 113 const caches = await openCaches(); 88 - const minRequest = new Request(request.url); 89 114 90 115 // Only store the content if we haven't seen this CID before 91 116 if (!(await caches.content.match(cidKey))) { 92 117 await caches.content.put(new Request(cidKey), response); 93 118 } 94 119 95 - if (!(await caches.index.match(minRequest))) { 96 - // Update the URL → CID map 97 - await caches.index.put( 98 - new Request(request.url), 99 - new Response(cid, { headers: { "content-type": "text/plain" } }), 100 - ); 101 - } 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 + ); 102 125 } 103 126 104 127 /** ··· 111 134 const caches = await openCaches(); 112 135 113 136 const indexEntry = await caches.index.match(request); 114 - if (!indexEntry) console.log("⚔️", request.url); 115 - 116 137 if (!indexEntry) return undefined; 117 138 118 139 const cid = await indexEntry.text(); 119 - console.log("REQ", request.url, cid); 120 140 return caches.content.match(cidUrl(cid)); 121 141 } 122 142 ··· 137 157 * @returns {Promise<Response>} 138 158 */ 139 159 async function handleFetch(request) { 140 - // When we know we're offline, skip the network entirely. 141 160 if (navigator.onLine) { 142 161 try { 143 162 return await fetchAndStore(request);