[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Support github: prefix and cache tangled not found responses

Grace Kind (Jul 20, 2026, 4:55 PM -0500) ab3cfba9 0090c2a2

+103 -14
+1 -1
plugins.md
··· 20 20 21 21 ## Publishing a plugin 22 22 23 - To publish a plugin version, tag a commit with the version number (e.g. "0.1.0", no v) and push it to a public GitHub repository. To include your plugin in the Community Plugins listing, make a pull request to https://github.com/improsocial/impro-releases with the plugin info. 23 + To publish a plugin version, tag a commit with the version number (e.g. "0.1.0", no v) and push it to a public GitHub or Tangled repository. To include your plugin in the Community Plugins listing, make a pull request to https://github.com/improsocial/impro-releases with the plugin info. 24 24 25 25 ## API surface 26 26
+13 -2
src/js/plugins/pluginCache.js
··· 8 8 return await caches.open(CACHE_NAME); 9 9 } 10 10 11 - async fetch(url, { doCacheNotFound = false } = {}) { 11 + async fetch( 12 + url, 13 + { doCacheNotFound = false, isNotFound = (status) => status === 404 } = {}, 14 + ) { 12 15 const cache = await this._getCache(); 13 16 let response = await cache.match(url); 14 17 if (!response) { 15 18 response = await fetch(url, { redirect: "follow" }); 16 - if (response.ok || (doCacheNotFound && response.status === 404)) { 19 + if (response.ok) { 17 20 await cache.put(url, response.clone()); 21 + } else if (doCacheNotFound) { 22 + const body = await response 23 + .clone() 24 + .text() 25 + .catch(() => null); 26 + if (isNotFound(response.status, body)) { 27 + await cache.put(url, response.clone()); 28 + } 18 29 } 19 30 } 20 31 if (!response.ok) {
+12 -9
src/js/plugins/sourceProvider.js
··· 15 15 } 16 16 17 17 // A plugin listing's `repo` field is normally a bare "owner/repo" GitHub 18 - // path. It may also be prefixed with a host name ("host:owner/repo") to 19 - // source from somewhere else — currently just "tangled:" for tangled.sh 20 - // repos. GitHub owner/repo names can't contain ":", so this is unambiguous. 18 + // path. It may also be prefixed with a host name ("host:owner/repo"): 19 + // "github:" spells out the default explicitly, and "tangled:" sources from 20 + // tangled.sh. GitHub owner/repo names can't contain ":", so this is 21 + // unambiguous. 21 22 export function parseRepoSpec(repo) { 22 23 const colonIndex = repo.indexOf(":"); 23 24 if (colonIndex === -1) return { host: "github", path: repo }; ··· 58 59 ); 59 60 } 60 61 61 - function isMissingFileStatus(repo, status, body) { 62 + function isMissingFileResponse(repo, status, body) { 62 63 if (status === 404) return true; 63 - if (status !== 500) return false; 64 64 return ( 65 - parseRepoSpec(repo).host === "tangled" && isTangledMissingBlobBody(body) 65 + parseRepoSpec(repo).host === "tangled" && 66 + status === 500 && 67 + isTangledMissingBlobBody(body) 66 68 ); 67 69 } 68 70 ··· 75 77 return `https://tangled.org/${path}/raw/${ref}/${file}`; 76 78 } 77 79 const ref = release ? `refs/tags/${release}` : "refs/heads/main"; 78 - return `https://raw.githubusercontent.com/${repo}/${ref}/${file}`; 80 + return `https://raw.githubusercontent.com/${path}/${ref}/${file}`; 79 81 } 80 82 81 83 export class SourceProvider { ··· 157 159 try { 158 160 const response = await this.pluginCache.fetch(url, { 159 161 doCacheNotFound: true, 162 + isNotFound: (status, body) => isMissingFileResponse(repo, status, body), 160 163 }); 161 164 return await response.text(); 162 165 } catch (error) { 163 - if (isMissingFileStatus(repo, error?.status, error?.body)) return null; 166 + if (isMissingFileResponse(repo, error?.status, error?.body)) return null; 164 167 throw error; 165 168 } 166 169 } ··· 180 183 const response = await fetch(url, { cache: "no-store" }); 181 184 const body = await response.text(); 182 185 if (!response.ok) { 183 - if (isMissingFileStatus(repo, response.status, body)) return null; 186 + if (isMissingFileResponse(repo, response.status, body)) return null; 184 187 throw new Error(`HTTP ${response.status}`); 185 188 } 186 189 return body;
+38 -1
tests/unit/specs/plugins/pluginCache.test.js
··· 145 145 assert.deepEqual(fetchStub.calls.length, 1); 146 146 }); 147 147 148 - it("does not cache non-404 errors even with doCacheNotFound", async () => { 148 + it("caches errors a custom isNotFound predicate matches on the body", async () => { 149 + fetchStub = stubFetch(async () => 150 + makeResponse("failed to get blob", { ok: false, status: 500 }), 151 + ); 152 + const cache = new PluginCache(); 153 + const url = "https://example.test/styles.css"; 154 + const options = { 155 + doCacheNotFound: true, 156 + isNotFound: (status, body) => 157 + status === 500 && body === "failed to get blob", 158 + }; 159 + const error = await cache 160 + .fetch(url, options) 161 + .then(null, (thrown) => thrown); 162 + assert.deepEqual(error.status, 500); 163 + assert.deepEqual(error.body, "failed to get blob"); 164 + await assert.rejects(cache.fetch(url, options), /500/); 165 + assert.deepEqual(fetchStub.calls.length, 1); 166 + }); 167 + 168 + it("does not cache non-404 errors with the default isNotFound", async () => { 149 169 fetchStub = stubFetch(async () => 150 170 makeResponse("boom", { ok: false, status: 500 }), 151 171 ); ··· 153 173 const url = "https://example.test/styles.css"; 154 174 await assert.rejects(cache.fetch(url, { doCacheNotFound: true }), /500/); 155 175 await assert.rejects(cache.fetch(url, { doCacheNotFound: true }), /500/); 176 + assert.deepEqual(fetchStub.calls.length, 2); 177 + }); 178 + 179 + it("does not cache not-found responses without doCacheNotFound", async () => { 180 + fetchStub = stubFetch(async () => 181 + makeResponse("nope", { ok: false, status: 404 }), 182 + ); 183 + const cache = new PluginCache(); 184 + const url = "https://example.test/styles.css"; 185 + await assert.rejects( 186 + cache.fetch(url, { isNotFound: (status) => status === 404 }), 187 + /404/, 188 + ); 189 + await assert.rejects( 190 + cache.fetch(url, { isNotFound: (status) => status === 404 }), 191 + /404/, 192 + ); 156 193 assert.deepEqual(fetchStub.calls.length, 2); 157 194 }); 158 195
+39 -1
tests/unit/specs/sourceProvider.test.js
··· 239 239 pluginCache.calls[0].url, 240 240 "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.0.0/styles.css", 241 241 ); 242 - assert.deepEqual(pluginCache.calls[0].options, { doCacheNotFound: true }); 242 + const { doCacheNotFound, isNotFound } = pluginCache.calls[0].options; 243 + assert.deepEqual(doCacheNotFound, true); 244 + assert.deepEqual(isNotFound(404, null), true); 245 + assert.deepEqual(isNotFound(500, "boom"), false); 243 246 assert.deepEqual(styles, "body{color:blue}"); 244 247 }); 245 248 ··· 276 279 } finally { 277 280 stub.restore(); 278 281 } 282 + }); 283 + 284 + it("accepts an explicit github: prefix on the repo", async () => { 285 + const pluginCache = fakePluginCache(async () => 286 + jsonResponse({ id: "alpha", name: "A", version: "1.0.0" }), 287 + ); 288 + const provider = new SourceProvider(pluginCache); 289 + const manifest = await provider.getManifest( 290 + "alpha", 291 + "1.0.0", 292 + "github:ow/alpha", 293 + ); 294 + assert.deepEqual( 295 + pluginCache.calls[0].url, 296 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.0.0/manifest.json", 297 + ); 298 + assert.deepEqual(manifest.id, "alpha"); 299 + }); 300 + 301 + it("getCacheUrls strips the github: prefix from URLs", async () => { 302 + const provider = new SourceProvider(null); 303 + const urls = await provider.getCacheUrls( 304 + "alpha", 305 + "1.2.3", 306 + "github:ow/alpha", 307 + ); 308 + assert.deepEqual(urls, [ 309 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.2.3/manifest.json", 310 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.2.3/main.js", 311 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.2.3/styles.css", 312 + ]); 279 313 }); 280 314 281 315 it("getStyles returns null when remote styles.css 404s", async () => { ··· 397 431 "tangled:ow/alpha", 398 432 ); 399 433 assert.deepEqual(styles, null); 434 + const { isNotFound } = pluginCache.calls[0].options; 435 + assert.deepEqual(isNotFound(500, TANGLED_MISSING_BLOB_BODY), true); 436 + assert.deepEqual(isNotFound(500, "boom"), false); 437 + assert.deepEqual(isNotFound(404, null), true); 400 438 }); 401 439 402 440 it("getStyles still throws a tangled 500 with an unrelated body", async () => {