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

fix: service worker and precalculate cids

+80 -25
+37
_config.ts
··· 13 import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; 14 import { wasmLoader } from "esbuild-plugin-wasm"; 15 16 const site = lume({ 17 dest: "./dist", 18 src: "./src", ··· 40 format: "esm", 41 minify: false, 42 // outExtension: { ".js": ".min.js" }, 43 platform: "browser", 44 plugins: [ 45 dotenvRun({ ··· 348 // leave as-is if the source file can't be read 349 } 350 } 351 }); 352 353 function htmlWithInlineJs({ content, match, jsPath }: {
··· 13 import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; 14 import { wasmLoader } from "esbuild-plugin-wasm"; 15 16 + import { create as createCID } from "~/common/cid.js"; 17 + 18 const site = lume({ 19 dest: "./dist", 20 src: "./src", ··· 42 format: "esm", 43 minify: false, 44 // outExtension: { ".js": ".min.js" }, 45 + external: ["./file-tree.json"], 46 platform: "browser", 47 plugins: [ 48 dotenvRun({ ··· 351 // leave as-is if the source file can't be read 352 } 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 + ); 388 }); 389 390 function htmlWithInlineJs({ content, match, jsPath }: {
+7 -9
deno.jsonc
··· 43 "subsonic-api": "npm:subsonic-api@^3.2.0", 44 "throttle-debounce": "npm:throttle-debounce@^5.0.2", 45 "xxh32": "npm:xxh32@^2.0.5", 46 47 // music-metadata 48 "@tokenizer/http": "npm:@tokenizer/http@^0.9.2", 49 "@tokenizer/range": "npm:@tokenizer/range@^0.13.0", 50 "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 61 // Build 62 "@atcute/lex-cli": "npm:@atcute/lex-cli@^2.5.3", ··· 73 "@astral/astral": "jsr:@astral/astral", 74 "@std/expect": "jsr:@std/expect", 75 "@std/testing/bdd": "jsr:@std/testing/bdd", 76 }, 77 "exports": { 78 // .js
··· 43 "subsonic-api": "npm:subsonic-api@^3.2.0", 44 "throttle-debounce": "npm:throttle-debounce@^5.0.2", 45 "xxh32": "npm:xxh32@^2.0.5", 46 + "webamp": "npm:webamp@^2.2.0", 47 48 // music-metadata 49 "@tokenizer/http": "npm:@tokenizer/http@^0.9.2", 50 "@tokenizer/range": "npm:@tokenizer/range@^0.13.0", 51 "music-metadata": "npm:music-metadata@^11.10.6", 52 53 // Build 54 "@atcute/lex-cli": "npm:@atcute/lex-cli@^2.5.3", ··· 65 "@astral/astral": "jsr:@astral/astral", 66 "@std/expect": "jsr:@std/expect", 67 "@std/testing/bdd": "jsr:@std/testing/bdd", 68 + 69 + // Paths 70 + "~/": "./src/", 71 + 72 + "@testing/": "./src/testing/", 73 + "@tests/": "./tests/", 74 }, 75 "exports": { 76 // .js
+1 -1
src/components/orchestrator/offline/element.js
··· 7 /** 8 * Registers a service worker that makes the page available offline. 9 * 10 - * All resources fetched by the page are cached as they load. 11 * While online, requests always go to the network (the cache is bypassed), 12 * and successful responses are stored for later. While offline, the cache 13 * is used as a fallback.
··· 7 /** 8 * Registers a service worker that makes the page available offline. 9 * 10 + * All resources, except audio & video, fetched by the page are cached as they load. 11 * While online, requests always go to the network (the cache is bypassed), 12 * and successful responses are stored for later. While offline, the cache 13 * is used as a fallback.
+1
src/file-tree.json
···
··· 1 + {}
+34 -15
src/service-worker-offline.js
··· 2 3 import { create as createCid } from "./common/cid.js"; 4 5 /** Media content types to ignore */ 6 const MEDIA_CONTENT_TYPE = /^(audio|video)\//; 7 ··· 48 }); 49 50 //////////////////////////////////////////// 51 - // CACHE (content-addressed) 52 //////////////////////////////////////////// 53 54 /** ··· 73 } 74 75 /** 76 * Computes the CID of `response`'s body and writes it into the two-level cache. 77 * The same content is stored only once, regardless of how many URLs reference it. 78 * 79 * @param {Request} request 80 * @param {Response} response - a clone; its body is fully consumed here 81 */ 82 async function store(request, response) { 83 - const bytes = new Uint8Array(await response.clone().arrayBuffer()); 84 - const cid = await createCid(RAW_CODEC, bytes); 85 const cidKey = cidUrl(cid); 86 87 const caches = await openCaches(); 88 - const minRequest = new Request(request.url); 89 90 // Only store the content if we haven't seen this CID before 91 if (!(await caches.content.match(cidKey))) { 92 await caches.content.put(new Request(cidKey), response); 93 } 94 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 - } 102 } 103 104 /** ··· 111 const caches = await openCaches(); 112 113 const indexEntry = await caches.index.match(request); 114 - if (!indexEntry) console.log("⚔️", request.url); 115 - 116 if (!indexEntry) return undefined; 117 118 const cid = await indexEntry.text(); 119 - console.log("REQ", request.url, cid); 120 return caches.content.match(cidUrl(cid)); 121 } 122 ··· 137 * @returns {Promise<Response>} 138 */ 139 async function handleFetch(request) { 140 - // When we know we're offline, skip the network entirely. 141 if (navigator.onLine) { 142 try { 143 return await fetchAndStore(request);
··· 2 3 import { create as createCid } from "./common/cid.js"; 4 5 + const fileTreePromise = import("./file-tree.json", { with: { type: "json" } }) 6 + .then((m) => m.default) 7 + .catch(() => null); 8 + 9 /** Media content types to ignore */ 10 const MEDIA_CONTENT_TYPE = /^(audio|video)\//; 11 ··· 52 }); 53 54 //////////////////////////////////////////// 55 + // CONTENT-ADDRESSED CACHE 56 //////////////////////////////////////////// 57 58 /** ··· 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 + */ 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 + /** 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 */ 104 async 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 /** ··· 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 ··· 157 * @returns {Promise<Response>} 158 */ 159 async function handleFetch(request) { 160 if (navigator.onLine) { 161 try { 162 return await fetchAndStore(request);