From e69ec6efae8c28f90e0315837cabff20a4d17a4d Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Sun, 16 Aug 2026 15:37:11 -0500 Subject: [PATCH] Moved tangled caching to indexeddb --- package.json | 2 +- src/js/plugins/sourceProvider.js | 230 ++++++++---------------- src/js/tangled.js | 158 ++++++++++++++++ tests/unit/specs/sourceProvider.test.js | 97 ++++++++++ tests/unit/specs/tangled.test.js | 184 +++++++++++++++++++ 5 files changed, 512 insertions(+), 159 deletions(-) create mode 100644 src/js/tangled.js create mode 100644 tests/unit/specs/tangled.test.js diff --git a/package.json b/package.json index 0da53adc..7c6f1f9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.211", + "version": "0.18.212", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/plugins/sourceProvider.js b/src/js/plugins/sourceProvider.js index f053ae11..8cac84a6 100644 --- a/src/js/plugins/sourceProvider.js +++ b/src/js/plugins/sourceProvider.js @@ -1,4 +1,4 @@ -import { resolveIdentity, getServiceEndpointFromDidDoc } from "/js/atproto.js"; +import { TangledResolver, decodeTangledBlobContent } from "/js/tangled.js"; const REQUIRED_MANIFEST_FIELDS = ["id", "name", "version"]; @@ -95,151 +95,57 @@ function assertFontMagicBytes(file, bytes) { } } -// tangled.org's own HTTP endpoints (the "/raw//" route and the -// mirror.tangled.network XRPC service it redirects through) don't set -// Access-Control-Allow-Origin, so browsers block fetching them cross-origin -// entirely — this isn't fixable by changing the URL we hit on that host. -// -// Instead, resolve and fetch entirely through standard AT Protocol -// infrastructure, which is CORS-enabled throughout (we already depend -// on plc.directory and the handle resolver for its own core function, so -// this adds no new trust surface): -// 1. resolveIdentity(ownerHandle) -> owner DID + DID doc (handle -// resolver + plc.directory) -// 2. getServiceEndpointFromDidDoc -> owner's PDS -// 3. the repo's own "sh.tangled.repo" record on the owner's PDS -> -// {knot, repoDid} (see findTangledRepoRecord below re. the two -// record-key schemes this has to handle) -// 4. the individual knot server's own "sh.tangled.repo.blob" XRPC route -// (not the mirror), which does set Access-Control-Allow-Origin: * -// -// Cached per repo path since each resolution costs multiple network -// round-trips and rarely changes. -const tangledRepoInfoCache = new Map(); - -// The "sh.tangled.repo" record key scheme has changed at least once: -// repos created more recently use the repo name directly as the record -// key (rkey), while older repos use an opaque TID key instead, with the -// repo name only present as an explicit "name" field on the record value -// (confirmed against real accounts, e.g. tangled.org's own "infra" repo -// still uses a TID key). Try the fast direct lookup first, and fall back -// to scanning the owner's records by name — this has to keep working for -// both existing schemes, and possibly future ones with the same fallback. -async function findTangledRepoRecord(pds, ownerDid, repoName) { - const directUrl = - `${pds}/xrpc/com.atproto.repo.getRecord?` + - new URLSearchParams({ - repo: ownerDid, - collection: "sh.tangled.repo", - rkey: repoName, - }); - const directResponse = await fetch(directUrl); - if (directResponse.ok) { - const record = await directResponse.json(); - if (record.value) return record.value; - } - - let cursor = null; - for (let page = 0; page < 20; page++) { - const params = new URLSearchParams({ - repo: ownerDid, - collection: "sh.tangled.repo", - limit: "100", - }); - if (cursor) params.set("cursor", cursor); - const response = await fetch( - `${pds}/xrpc/com.atproto.repo.listRecords?${params}`, - ); - if (!response.ok) return null; - const data = await response.json(); - const records = data.records ?? []; - const match = records.find((record) => record.value?.name === repoName); - if (match) return match.value; - if (!data.cursor || records.length === 0) return null; - cursor = data.cursor; +// Mirrors PluginCache.fetch's error shape, whose `status` marks an error the +// server answered with rather than a network-level failure. +async function fetchOrThrow(url) { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) { + const error = new Error(`HTTP ${response.status}`); + error.status = response.status; + throw error; } - return null; + return response; } -async function resolveTangledRepoInfo(path) { - if (tangledRepoInfoCache.has(path)) { - return tangledRepoInfoCache.get(path); +export class SourceProvider { + constructor(pluginCache, tangledResolver = new TangledResolver()) { + this.pluginCache = pluginCache; + this.tangledResolver = tangledResolver; } - const promise = (async () => { - const slashIndex = path.indexOf("/"); - if (slashIndex === -1) { - throw new Error(`Invalid tangled repo path "${path}"`); - } - const ownerHandle = path.slice(0, slashIndex); - const repoName = path.slice(slashIndex + 1); - const identity = await resolveIdentity(ownerHandle); - if (!identity) { - throw new Error(`Could not resolve tangled repo owner "${ownerHandle}"`); - } - const pds = getServiceEndpointFromDidDoc(identity.didDoc); - - const record = await findTangledRepoRecord(pds, identity.did, repoName); - if (!record) { - throw new Error( - `Could not find a tangled repo record named "${repoName}" for "${ownerHandle}"`, - ); - } - const { knot, repoDid } = record; - if (!knot || !repoDid) { - throw new Error( - `tangled repo record for "${path}" is missing knot/repoDid`, - ); + // The knot's raw=true mode only serves image/video/text mime types (fonts + // come back 403 "only image, video, and text files can be accessed + // directly"). Passing raw=false instead gets the JSON-wrapped response + // (content + encoding, "base64" for binary files) that every file type + // supports — needed for fonts, usable for any file type. + async _remoteAssetUrl({ repo, file, release = null, raw = true }) { + const { host, path } = parseRepoSpec(repo); + if (host === "tangled") { + const { knot, repoDid } = + await this.tangledResolver.resolveRepoInfo(path); + const params = new URLSearchParams({ + repo: repoDid, + ref: release ?? "main", + path: file, + }); + if (raw) params.set("raw", "true"); + return `https://${knot}/xrpc/sh.tangled.repo.blob?${params}`; } - return { knot, repoDid }; - })(); - // Don't cache a failed resolution — allow retrying on a later call. - promise.catch(() => tangledRepoInfoCache.delete(path)); - tangledRepoInfoCache.set(path, promise); - return promise; -} - -// The knot's raw=true mode only serves image/video/text mime types (fonts -// come back 403 "only image, video, and text files can be accessed -// directly"). Passing raw=false instead gets the JSON-wrapped response -// (content + encoding, "base64" for binary files) that every file type -// supports — needed for fonts, usable for any file type. -async function remoteAssetUrl({ repo, file, release = null, raw = true }) { - const { host, path } = parseRepoSpec(repo); - if (host === "tangled") { - const { knot, repoDid } = await resolveTangledRepoInfo(path); - const params = new URLSearchParams({ - repo: repoDid, - ref: release ?? "main", - path: file, - }); - if (raw) params.set("raw", "true"); - return `https://${knot}/xrpc/sh.tangled.repo.blob?${params}`; + const ref = release ? `refs/tags/${release}` : "refs/heads/main"; + return `https://raw.githubusercontent.com/${path}/${ref}/${file}`; } - const ref = release ? `refs/tags/${release}` : "refs/heads/main"; - return `https://raw.githubusercontent.com/${path}/${ref}/${file}`; -} -// Decodes a tangled knot's JSON-wrapped blob response (from a non-raw -// sh.tangled.repo.blob fetch) into an ArrayBuffer. -function decodeTangledBlobContent(data, file) { - if (typeof data.content !== "string") { - throw new Error(`tangled blob response for "${file}" has no content`); - } - if (data.encoding === "base64") { - const binary = atob(data.content); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); + async _fetchRequiredAsset(repo, urlOptions, doFetch) { + const url = await this._remoteAssetUrl({ repo, ...urlOptions }); + try { + return await doFetch(url); + } catch (error) { + const { host, path } = parseRepoSpec(repo); + if (host === "tangled" && typeof error?.status === "number") { + await this.tangledResolver.invalidate(path); + } + throw error; } - return bytes.buffer; - } - return new TextEncoder().encode(data.content).buffer; -} - -export class SourceProvider { - constructor(pluginCache) { - this.pluginCache = pluginCache; } async getManifest(pluginId, version, repo) { @@ -253,12 +159,11 @@ export class SourceProvider { if (!version || !repo) { throw new Error("Version and repo are required"); } - const url = await remoteAssetUrl({ + const response = await this._fetchRequiredAsset( repo, - file: "manifest.json", - release: version, - }); - const response = await this.pluginCache.fetch(url); + { file: "manifest.json", release: version }, + (url) => this.pluginCache.fetch(url), + ); return parsePluginManifest(pluginId, await response.json()); } @@ -270,9 +175,11 @@ export class SourceProvider { throw new Error("Repo is required"); } // Fetch from main branch - const url = await remoteAssetUrl({ repo, file: "manifest.json" }); - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + const response = await this._fetchRequiredAsset( + repo, + { file: "manifest.json" }, + fetchOrThrow, + ); return parsePluginManifest(pluginId, await response.json()); } @@ -280,9 +187,11 @@ export class SourceProvider { if (!repo) { throw new Error("Repo is required"); } - const url = await remoteAssetUrl({ repo, file: "manifest.json" }); - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + const response = await this._fetchRequiredAsset( + repo, + { file: "manifest.json" }, + fetchOrThrow, + ); const manifest = await response.json(); return parsePluginManifest(manifest.id, manifest); } @@ -296,12 +205,11 @@ export class SourceProvider { if (!version || !repo) { throw new Error("Version and repo are required"); } - const url = await remoteAssetUrl({ + const response = await this._fetchRequiredAsset( repo, - file: "main.js", - release: version, - }); - const response = await this.pluginCache.fetch(url); + { file: "main.js", release: version }, + (url) => this.pluginCache.fetch(url), + ); return await response.text(); } @@ -316,7 +224,7 @@ export class SourceProvider { if (!version || !repo) { throw new Error("Version and repo are required"); } - const url = await remoteAssetUrl({ + const url = await this._remoteAssetUrl({ repo, file: "styles.css", release: version, @@ -344,7 +252,7 @@ export class SourceProvider { } const { host } = parseRepoSpec(repo); if (host === "tangled") { - const url = await remoteAssetUrl({ + const url = await this._remoteAssetUrl({ repo, file, release: version, @@ -353,7 +261,11 @@ export class SourceProvider { const response = await this.pluginCache.fetch(url); bytes = decodeTangledBlobContent(await response.json(), file); } else { - const url = await remoteAssetUrl({ repo, file, release: version }); + const url = await this._remoteAssetUrl({ + repo, + file, + release: version, + }); const response = await this.pluginCache.fetch(url); bytes = await response.arrayBuffer(); } @@ -374,7 +286,7 @@ export class SourceProvider { throw new Error("Repo is required"); } // Fetch from main branch so we show the latest README - const url = await remoteAssetUrl({ repo, file: "README.md" }); + const url = await this._remoteAssetUrl({ repo, file: "README.md" }); const response = await fetch(url, { cache: "no-store" }); if (response.status === 404) return null; if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -398,7 +310,9 @@ export class SourceProvider { // reconcile doesn't purge a partially-cached plugin. } return await Promise.all( - files.map((file) => remoteAssetUrl({ repo, file, release: version })), + files.map((file) => + this._remoteAssetUrl({ repo, file, release: version }), + ), ); } } diff --git a/src/js/tangled.js b/src/js/tangled.js new file mode 100644 index 00000000..175e2593 --- /dev/null +++ b/src/js/tangled.js @@ -0,0 +1,158 @@ +import { resolveIdentity, getServiceEndpointFromDidDoc } from "/js/atproto.js"; +import { KVIndexedDB } from "/js/utils.js"; + +export function decodeTangledBlobContent(data, file) { + if (typeof data.content !== "string") { + throw new Error(`tangled blob response for "${file}" has no content`); + } + if (data.encoding === "base64") { + const binary = atob(data.content); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } + return new TextEncoder().encode(data.content).buffer; +} + +async function findRepoRecord(pds, ownerDid, repoName) { + const directUrl = + `${pds}/xrpc/com.atproto.repo.getRecord?` + + new URLSearchParams({ + repo: ownerDid, + collection: "sh.tangled.repo", + rkey: repoName, + }); + const directResponse = await fetch(directUrl); + if (directResponse.ok) { + const record = await directResponse.json(); + if (record.value) return record.value; + } + let cursor = null; + for (let page = 0; page < 20; page++) { + const params = new URLSearchParams({ + repo: ownerDid, + collection: "sh.tangled.repo", + limit: "100", + }); + if (cursor) params.set("cursor", cursor); + const response = await fetch( + `${pds}/xrpc/com.atproto.repo.listRecords?${params}`, + ); + if (!response.ok) return null; + const data = await response.json(); + const records = data.records ?? []; + const match = records.find((record) => record.value?.name === repoName); + if (match) return match.value; + if (!data.cursor || records.length === 0) return null; + cursor = data.cursor; + } + return null; +} + +const REPO_INFO_REVALIDATE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; + +// Resolves an "/" path to the {knot, repoDid} pair its +// blobs are served from. +export class TangledResolver { + constructor() { + this._pending = new Map(); + this._store = new KVIndexedDB("tangled-repo-info", "repoInfoByPath"); + } + + async resolveRepoInfo(path) { + if (this._pending.has(path)) { + return this._pending.get(path); + } + const promise = (async () => { + const persisted = await this._read(path); + if (persisted?.knot && persisted?.repoDid) { + const age = Date.now() - (persisted.resolvedAt ?? 0); + if (age > REPO_INFO_REVALIDATE_AFTER_MS) { + this._revalidate(path); + } + return { knot: persisted.knot, repoDid: persisted.repoDid }; + } + const info = await this._resolveFromNetwork(path); + await this._write(path, info); + return info; + })(); + // Don't cache a failed resolution — allow retrying on a later call. + promise.catch(() => this._pending.delete(path)); + this._pending.set(path, promise); + return promise; + } + + async invalidate(path) { + this._pending.delete(path); + try { + await this._store.delete(path); + } catch (error) { + console.warn( + `Could not clear cached tangled repo info for "${path}"`, + error, + ); + } + } + + async _resolveFromNetwork(path) { + const slashIndex = path.indexOf("/"); + if (slashIndex === -1) { + throw new Error(`Invalid tangled repo path "${path}"`); + } + const ownerHandle = path.slice(0, slashIndex); + const repoName = path.slice(slashIndex + 1); + + const identity = await resolveIdentity(ownerHandle); + if (!identity) { + throw new Error(`Could not resolve tangled repo owner "${ownerHandle}"`); + } + const pds = getServiceEndpointFromDidDoc(identity.didDoc); + + const record = await findRepoRecord(pds, identity.did, repoName); + if (!record) { + throw new Error( + `Could not find a tangled repo record named "${repoName}" for "${ownerHandle}"`, + ); + } + const { knot, repoDid } = record; + if (!knot || !repoDid) { + throw new Error( + `tangled repo record for "${path}" is missing knot/repoDid`, + ); + } + return { knot, repoDid }; + } + + _revalidate(path) { + this._resolveFromNetwork(path) + .then((info) => this._write(path, info)) + .catch((error) => { + console.warn( + `Could not revalidate tangled repo info for "${path}"`, + error, + ); + }); + } + + async _read(path) { + try { + return (await this._store.get(path)) ?? null; + } catch (error) { + console.warn( + `Could not read cached tangled repo info for "${path}"`, + error, + ); + return null; + } + } + + async _write(path, { knot, repoDid }) { + try { + await this._store.put(path, { knot, repoDid, resolvedAt: Date.now() }); + } catch (error) { + console.warn(`Could not cache tangled repo info for "${path}"`, error); + } + } +} diff --git a/tests/unit/specs/sourceProvider.test.js b/tests/unit/specs/sourceProvider.test.js index 0e3558ad..32bd30a5 100644 --- a/tests/unit/specs/sourceProvider.test.js +++ b/tests/unit/specs/sourceProvider.test.js @@ -946,3 +946,100 @@ describe("SourceProvider.getFont with tangled.sh-hosted plugins", () => { } }); }); + +// The knot binding outlives the session, so a repo that moved to a different +// knot would keep failing against the old one. These cover which failures the +// provider reads as "this binding is wrong" — see _fetchRequiredAsset. +function fakeTangledResolver() { + return { + invalidated: [], + async resolveRepoInfo() { + return { knot: "knot.example", repoDid: "did:plc:repo" }; + }, + async invalidate(path) { + this.invalidated.push(path); + }, + }; +} + +function httpError(status) { + const error = new Error(`HTTP ${status}`); + error.status = status; + return error; +} + +describe("SourceProvider tangled knot invalidation", () => { + const repo = "tangled:owner.example/alpha"; + const path = "owner.example/alpha"; + + function providerThatFailsWith(error) { + const resolver = fakeTangledResolver(); + const provider = new SourceProvider( + fakePluginCache(async () => { + throw error; + }), + resolver, + ); + return { provider, resolver }; + } + + it("drops the binding when the knot errors on a versioned manifest", async () => { + const { provider, resolver } = providerThatFailsWith(httpError(404)); + + await assert.rejects(() => provider.getManifest("alpha", "1.0.0", repo)); + + assert.deepEqual(resolver.invalidated, [path]); + }); + + it("drops the binding when the knot errors on main.js", async () => { + const { provider, resolver } = providerThatFailsWith(httpError(500)); + + await assert.rejects(() => provider.getSource("alpha", "1.0.0", repo)); + + assert.deepEqual(resolver.invalidated, [path]); + }); + + it("drops the binding when the knot errors on a live manifest", async () => { + const resolver = fakeTangledResolver(); + const stub = stubFetch(async () => + jsonResponse({}, { ok: false, status: 404 }), + ); + try { + const provider = new SourceProvider(null, resolver); + await assert.rejects(() => provider.getLiveManifest("alpha", repo)); + } finally { + stub.restore(); + } + assert.deepEqual(resolver.invalidated, [path]); + }); + + it("keeps the binding on a network-level failure", async () => { + // Indistinguishable from being offline, where the binding is what lets a + // warm cache still serve the plugin + const { provider, resolver } = providerThatFailsWith( + new TypeError("Failed to fetch"), + ); + + await assert.rejects(() => provider.getSource("alpha", "1.0.0", repo)); + + assert.deepEqual(resolver.invalidated, []); + }); + + it("keeps the binding when an optional styles.css is missing", async () => { + const { provider, resolver } = providerThatFailsWith(httpError(404)); + + assert.deepEqual(await provider.getStyles("alpha", "1.0.0", repo), null); + + assert.deepEqual(resolver.invalidated, []); + }); + + it("does not invalidate for GitHub-hosted repos", async () => { + const { provider, resolver } = providerThatFailsWith(httpError(404)); + + await assert.rejects(() => + provider.getManifest("alpha", "1.0.0", "ow/alpha"), + ); + + assert.deepEqual(resolver.invalidated, []); + }); +}); diff --git a/tests/unit/specs/tangled.test.js b/tests/unit/specs/tangled.test.js new file mode 100644 index 00000000..77962661 --- /dev/null +++ b/tests/unit/specs/tangled.test.js @@ -0,0 +1,184 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { TangledResolver, decodeTangledBlobContent } from "/js/tangled.js"; +import { MockFetch, installFakeIndexedDB } from "../testHelpers.js"; + +describe("TangledResolver", () => { + const originalFetch = globalThis.fetch; + const originalDateNow = Date.now; + const ownerDid = "did:plc:owner"; + const knot = "knot1.tangled.sh"; + const repoDid = "did:plc:repo"; + + let identityResolutions; + let resolver; + // A fresh path per test: the persisted binding is keyed by it, so reusing + // one would let an earlier test's entry satisfy a later test's first call + let pathCounter = 0; + + function nextPath() { + pathCounter += 1; + return `owner.example/tags-${pathCounter}`; + } + + function stubResolutionChain() { + const fetchMock = globalThis.fetch; + fetchMock.__intercept(/resolveHandle/, async () => { + identityResolutions += 1; + return { ok: true, status: 200, json: async () => ({ did: ownerDid }) }; + }); + fetchMock.__interceptJson(/plc\.directory/, { + alsoKnownAs: ["at://owner.example"], + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://pds.example", + }, + ], + }); + fetchMock.__interceptJson(/com\.atproto\.repo\.getRecord/, { + value: { knot, repoDid }, + }); + } + + beforeEach(() => { + installFakeIndexedDB(); + globalThis.fetch = new MockFetch(); + identityResolutions = 0; + resolver = new TangledResolver(); + stubResolutionChain(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + Date.now = originalDateNow; + }); + + async function flush() { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + it("resolves through the owner's PDS on a cold cache", async () => { + const info = await resolver.resolveRepoInfo(nextPath()); + + assert.deepEqual(info, { knot, repoDid }); + assert.deepEqual(identityResolutions, 1); + }); + + it("memoizes within a session", async () => { + const path = nextPath(); + await resolver.resolveRepoInfo(path); + await resolver.resolveRepoInfo(path); + + assert.deepEqual(identityResolutions, 1); + }); + + it("serves the persisted binding without re-resolving the owner handle", async () => { + const path = nextPath(); + await resolver.resolveRepoInfo(path); + assert.deepEqual(identityResolutions, 1); + + // A fresh resolver stands in for a new session: only the persisted + // entry can satisfy this call + const info = await new TangledResolver().resolveRepoInfo(path); + + assert.deepEqual(info, { knot, repoDid }); + assert.deepEqual(identityResolutions, 1); + }); + + it("still serves the persisted binding when the handle no longer resolves", async () => { + const path = nextPath(); + await resolver.resolveRepoInfo(path); + + globalThis.fetch = new MockFetch(); + identityResolutions = 0; + globalThis.fetch.__intercept(/resolveHandle/, async () => { + throw new Error("resolveHandle: timed out"); + }); + + const info = await new TangledResolver().resolveRepoInfo(path); + + assert.deepEqual(info, { knot, repoDid }); + assert.deepEqual(identityResolutions, 0); + }); + + it("serves a stale binding immediately and re-resolves in the background", async () => { + const path = nextPath(); + Date.now = () => originalDateNow() - 30 * 24 * 60 * 60 * 1000; + await resolver.resolveRepoInfo(path); + Date.now = originalDateNow; + + identityResolutions = 0; + const info = await new TangledResolver().resolveRepoInfo(path); + + // Served without waiting on the revalidation + assert.deepEqual(info, { knot, repoDid }); + await flush(); + assert.deepEqual(identityResolutions, 1); + }); + + it("re-resolves after invalidate", async () => { + const path = nextPath(); + await resolver.resolveRepoInfo(path); + await resolver.invalidate(path); + await resolver.resolveRepoInfo(path); + + assert.deepEqual(identityResolutions, 2); + }); + + it("drops the persisted binding on invalidate", async () => { + const path = nextPath(); + await resolver.resolveRepoInfo(path); + await resolver.invalidate(path); + + identityResolutions = 0; + const info = await new TangledResolver().resolveRepoInfo(path); + + assert.deepEqual(info, { knot, repoDid }); + assert.deepEqual(identityResolutions, 1); + }); + + it("does not cache a failed resolution", async () => { + const path = nextPath(); + globalThis.fetch = new MockFetch(); + globalThis.fetch.__intercept(/resolveHandle/, async () => { + identityResolutions += 1; + throw new Error("nope"); + }); + + await assert.rejects(() => resolver.resolveRepoInfo(path)); + await assert.rejects(() => resolver.resolveRepoInfo(path)); + + assert.deepEqual(identityResolutions, 2); + }); +}); + +describe("decodeTangledBlobContent", () => { + function toText(buffer) { + return new TextDecoder().decode(new Uint8Array(buffer)); + } + + it("decodes a base64-encoded blob", () => { + const buffer = decodeTangledBlobContent( + { content: btoa("hello"), encoding: "base64" }, + "main.js", + ); + + assert.deepEqual(toText(buffer), "hello"); + }); + + it("encodes plain text content as utf-8", () => { + const buffer = decodeTangledBlobContent({ content: "héllo" }, "main.js"); + + assert.deepEqual(toText(buffer), "héllo"); + assert.deepEqual(buffer.byteLength, 6); + }); + + it("throws when the response has no content", () => { + assert.throws( + () => decodeTangledBlobContent({ encoding: "base64" }, "font.woff2"), + /font\.woff2/, + ); + }); +}); -- 2.51.2