diff --git a/_config.ts b/_config.ts index 09d19633..6700984a 100644 --- a/_config.ts +++ b/_config.ts @@ -13,6 +13,8 @@ import { walkSync } from "@std/fs/walk"; import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; import { wasmLoader } from "esbuild-plugin-wasm"; +import { create as createCID } from "~/common/cid.js"; + const site = lume({ dest: "./dist", src: "./src", @@ -40,6 +42,7 @@ site.use(esbuild({ format: "esm", minify: false, // outExtension: { ".js": ".min.js" }, + external: ["./file-tree.json"], platform: "browser", plugins: [ dotenvRun({ @@ -350,6 +353,40 @@ site.addEventListener("afterBuild", async () => { } }); +site.addEventListener("afterBuild", async () => { + const RAW = 0x55; + + async function buildFileTree( + dir: string, + prefix = "", + ): Promise> { + const tree: Record = {}; + + for (const entry of Deno.readDirSync(dir)) { + const entryPath = path.join(dir, entry.name); + const entryKey = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory) { + Object.assign(tree, await buildFileTree(entryPath, entryKey)); + } else { + const data = Deno.readFileSync(entryPath); + tree[entryKey] = await createCID(RAW, data); + } + } + + return tree; + } + + const tree = await buildFileTree("dist/"); + const sorted = Object.fromEntries( + Object.keys(tree).sort().map((k) => [k, tree[k]]), + ); + + Deno.writeTextFileSync( + "./dist/file-tree.json", + JSON.stringify(sorted, null, 2), + ); +}); + function htmlWithInlineJs({ content, match, jsPath }: { content: string; match: string; diff --git a/deno.jsonc b/deno.jsonc index 9c6d1f3d..8f400169 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -43,21 +43,13 @@ "subsonic-api": "npm:subsonic-api@^3.2.0", "throttle-debounce": "npm:throttle-debounce@^5.0.2", "xxh32": "npm:xxh32@^2.0.5", + "webamp": "npm:webamp@^2.2.0", // music-metadata "@tokenizer/http": "npm:@tokenizer/http@^0.9.2", "@tokenizer/range": "npm:@tokenizer/range@^0.13.0", "music-metadata": "npm:music-metadata@^11.10.6", - // Webamp - "webamp": "npm:webamp@^2.2.0", - - // Paths - "@testing/": "./src/testing/", - "@tests/": "./tests/", - - "~/": "./src/", - // Build "@atcute/lex-cli": "npm:@atcute/lex-cli@^2.5.3", "@dotenv-run/esbuild": "npm:@dotenv-run/esbuild@^1.5.1", @@ -73,6 +65,12 @@ "@astral/astral": "jsr:@astral/astral", "@std/expect": "jsr:@std/expect", "@std/testing/bdd": "jsr:@std/testing/bdd", + + // Paths + "~/": "./src/", + + "@testing/": "./src/testing/", + "@tests/": "./tests/", }, "exports": { // .js diff --git a/src/components/orchestrator/offline/element.js b/src/components/orchestrator/offline/element.js index 3667095a..345888dc 100644 --- a/src/components/orchestrator/offline/element.js +++ b/src/components/orchestrator/offline/element.js @@ -7,7 +7,7 @@ import { DiffuseElement } from "~/common/element.js"; /** * Registers a service worker that makes the page available offline. * - * All resources fetched by the page are cached as they load. + * All resources, except audio & video, fetched by the page are cached as they load. * While online, requests always go to the network (the cache is bypassed), * and successful responses are stored for later. While offline, the cache * is used as a fallback. diff --git a/src/file-tree.json b/src/file-tree.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/file-tree.json @@ -0,0 +1 @@ +{} diff --git a/src/service-worker-offline.js b/src/service-worker-offline.js index 9c8553aa..403a1aa9 100644 --- a/src/service-worker-offline.js +++ b/src/service-worker-offline.js @@ -2,6 +2,10 @@ import { create as createCid } from "./common/cid.js"; +const fileTreePromise = import("./file-tree.json", { with: { type: "json" } }) + .then((m) => m.default) + .catch(() => null); + /** Media content types to ignore */ const MEDIA_CONTENT_TYPE = /^(audio|video)\//; @@ -48,7 +52,7 @@ self.addEventListener("fetch", (_event) => { }); //////////////////////////////////////////// -// CACHE (content-addressed) +// CONTENT-ADDRESSED CACHE //////////////////////////////////////////// /** @@ -72,33 +76,52 @@ async function openCaches() { return { index, content }; } +/** + * Looks up a pathname in the pre-built file tree and returns its CID, or + * `undefined` if the entry is absent or the tree failed to load. + * + * @param {string} pathname - e.g. "/components/foo.js" + * @returns {Promise} + */ +async function cidFromTree(pathname) { + /** @type {Record | null} */ + const tree = await fileTreePromise; + if (!tree) return undefined; + const key = pathname.replace(/^\//, ""); + return tree[key]; +} + /** * Computes the CID of `response`'s body and writes it into the two-level cache. * The same content is stored only once, regardless of how many URLs reference it. * + * Uses the pre-built file tree CID when available; falls back to hashing the + * response body when the entry is missing from the tree. + * * @param {Request} request * @param {Response} response - a clone; its body is fully consumed here */ async function store(request, response) { - const bytes = new Uint8Array(await response.clone().arrayBuffer()); - const cid = await createCid(RAW_CODEC, bytes); + const { pathname } = new URL(request.url); + const cid = await cidFromTree(pathname) ?? + await createCid( + RAW_CODEC, + new Uint8Array(await response.clone().arrayBuffer()), + ); const cidKey = cidUrl(cid); const caches = await openCaches(); - const minRequest = new Request(request.url); // Only store the content if we haven't seen this CID before if (!(await caches.content.match(cidKey))) { await caches.content.put(new Request(cidKey), response); } - if (!(await caches.index.match(minRequest))) { - // Update the URL → CID map - await caches.index.put( - new Request(request.url), - new Response(cid, { headers: { "content-type": "text/plain" } }), - ); - } + // Update the URL → CID map + await caches.index.put( + new Request(request.url), + new Response(cid, { headers: { "content-type": "text/plain" } }), + ); } /** @@ -111,12 +134,9 @@ async function lookup(request) { const caches = await openCaches(); const indexEntry = await caches.index.match(request); - if (!indexEntry) console.log("⚔️", request.url); - if (!indexEntry) return undefined; const cid = await indexEntry.text(); - console.log("REQ", request.url, cid); return caches.content.match(cidUrl(cid)); } @@ -137,7 +157,6 @@ async function lookup(request) { * @returns {Promise} */ async function handleFetch(request) { - // When we know we're offline, skip the network entirely. if (navigator.onLine) { try { return await fetchAndStore(request);