[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.

Add plugin style loading

Grace Kind (May 16, 2026, 3:01 PM -0500) e90666ae ba8ff235

+442 -6
+7 -1
eleventy.config.js
··· 21 21 })) { 22 22 if (!entry.isSymbolicLink()) continue; 23 23 const realPath = fs.realpathSync(path.join("plugins-local", entry.name)); 24 - eleventyConfig.addWatchTarget(`${realPath}/{manifest.json,main.js}`); 24 + eleventyConfig.addWatchTarget( 25 + `${realPath}/{manifest.json,main.js,styles.css}`, 26 + ); 25 27 } 26 28 } 27 29 ··· 51 53 fs.mkdirSync(destDir, { recursive: true }); 52 54 fs.copyFileSync(manifestPath, path.join(destDir, "manifest.json")); 53 55 fs.copyFileSync(mainPath, path.join(destDir, "main.js")); 56 + const stylesPath = path.join(pluginPath, "styles.css"); 57 + if (fs.existsSync(stylesPath)) { 58 + fs.copyFileSync(stylesPath, path.join(destDir, "styles.css")); 59 + } 54 60 } 55 61 fs.writeFileSync( 56 62 "build/plugins-local/index.json",
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.14.37", 3 + "version": "0.14.38", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+22 -1
src/js/plugins/pluginBridge.js
··· 204 204 } 205 205 206 206 export class PluginBridge { 207 - constructor(sourceProvider) { 207 + constructor(sourceProvider, pluginStylesLoader) { 208 208 this._provider = sourceProvider; 209 + this._pluginStylesLoader = pluginStylesLoader; 209 210 this._registrationTargets = new Map(); 210 211 this._loadedPlugins = new Map(); 211 212 this._hostCallHandlers = new Map(); ··· 274 275 ); 275 276 throw new Error("Failed to load plugin source"); 276 277 } 278 + let cssText; 279 + try { 280 + cssText = await this._provider.getStyles(pluginId, version, repo); 281 + } catch (error) { 282 + logger.error( 283 + `failed to load "${pluginId}": could not fetch styles.css`, 284 + error, 285 + ); 286 + throw new Error("Failed to load plugin styles"); 287 + } 288 + if (cssText != null) { 289 + try { 290 + this._pluginStylesLoader.mount(pluginId, cssText); 291 + } catch (error) { 292 + logger.error(`failed to load "${pluginId}": invalid styles.css`, error); 293 + throw new Error("Plugin styles failed validation"); 294 + } 295 + } 277 296 try { 278 297 const pluginInstance = await PluginInstance.loadFromSource( 279 298 pluginId, ··· 289 308 logger.info(`loaded "${pluginId}" v${manifest.version}`); 290 309 return pluginInstance; 291 310 } catch (error) { 311 + this._pluginStylesLoader.unmount(pluginId); 292 312 logger.error(`"${pluginId}" failed during initialization:`, error); 293 313 throw new Error("Plugin failed during initialization"); 294 314 } ··· 349 369 if (!instance) return; 350 370 instance.unload(); 351 371 this._loadedPlugins.delete(pluginId); 372 + this._pluginStylesLoader.unmount(pluginId); 352 373 } 353 374 354 375 async reloadPlugin(pluginId, version, repo) {
+5 -1
src/js/plugins/pluginCache.js
··· 13 13 let response = await cache.match(url); 14 14 if (!response) { 15 15 response = await fetch(url, { redirect: "follow" }); 16 - if (!response.ok) throw new Error(`HTTP ${response.status} ${url}`); 16 + if (!response.ok) { 17 + const error = new Error(`HTTP ${response.status} ${url}`); 18 + error.status = response.status; 19 + throw error; 20 + } 17 21 await cache.put(url, response.clone()); 18 22 } 19 23 return response;
+6 -1
src/js/plugins/pluginService.js
··· 9 9 import { PluginCache } from "/js/plugins/pluginCache.js"; 10 10 import { PluginPreferencesManager } from "/js/plugins/pluginPreferencesManager.js"; 11 11 import { SourceProvider } from "/js/plugins/sourceProvider.js"; 12 + import { PluginStylesLoader } from "/js/plugins/pluginStylesLoader.js"; 12 13 import { compareVersions, isDev } from "/js/utils.js"; 13 14 import { EventEmitter } from "/js/eventEmitter.js"; 14 15 import { PLUGIN_REGISTRY_URL } from "/js/config.js"; ··· 61 62 : null; 62 63 this.pluginCache = new PluginCache(); 63 64 this.sourceProvider = new SourceProvider(this.pluginCache); 64 - this.pluginBridge = new PluginBridge(this.sourceProvider); 65 + this.pluginStylesLoader = new PluginStylesLoader(); 66 + this.pluginBridge = new PluginBridge( 67 + this.sourceProvider, 68 + this.pluginStylesLoader, 69 + ); 65 70 this.pluginRenderer = new PluginRenderer(this.pluginBridge); 66 71 this.prefManager = new PluginPreferencesManager(preferencesProvider); 67 72 this.session = session;
+51
src/js/plugins/pluginStylesLoader.js
··· 1 + const URL_FUNC_RE = 2 + /\b(?:url|image-set|-webkit-image-set|image|cross-fade|element)\s*\(/i; 3 + 4 + export function validatePluginCss(text) { 5 + const sheet = new CSSStyleSheet(); 6 + sheet.replaceSync(text); 7 + walk(sheet.cssRules); 8 + return sheet; 9 + } 10 + 11 + function walk(rules) { 12 + for (const rule of rules) { 13 + if (rule instanceof CSSImportRule) throw new Error("@import not allowed"); 14 + if (rule instanceof CSSFontFaceRule) 15 + throw new Error("@font-face not allowed"); 16 + if (rule instanceof CSSNamespaceRule) 17 + throw new Error("@namespace not allowed"); 18 + 19 + if (rule.style) { 20 + for (const prop of rule.style) { 21 + if (URL_FUNC_RE.test(rule.style.getPropertyValue(prop))) { 22 + throw new Error(`disallowed url() in ${prop}`); 23 + } 24 + } 25 + } 26 + 27 + if (rule.cssRules) walk(rule.cssRules); 28 + } 29 + } 30 + 31 + export class PluginStylesLoader { 32 + constructor() { 33 + this._sheets = new Map(); 34 + } 35 + 36 + mount(pluginId, cssText) { 37 + if (this._sheets.has(pluginId)) this.unmount(pluginId); 38 + const sheet = validatePluginCss(cssText); 39 + document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; 40 + this._sheets.set(pluginId, sheet); 41 + } 42 + 43 + unmount(pluginId) { 44 + const sheet = this._sheets.get(pluginId); 45 + if (!sheet) return; 46 + document.adoptedStyleSheets = document.adoptedStyleSheets.filter( 47 + (entry) => entry !== sheet, 48 + ); 49 + this._sheets.delete(pluginId); 50 + } 51 + }
+22
src/js/plugins/sourceProvider.js
··· 78 78 return await response.text(); 79 79 } 80 80 81 + // Returns CSS text if the plugin includes a styles.css, otherwise null. 82 + async getStyles(pluginId, version, repo) { 83 + if (pluginId.endsWith("__LOCAL")) { 84 + const response = await fetch(`/plugins-local/${pluginId}/styles.css`); 85 + if (response.status === 404) return null; 86 + if (!response.ok) throw new Error(`HTTP ${response.status}`); 87 + return await response.text(); 88 + } 89 + if (!version || !repo) { 90 + throw new Error("Version and repo are required"); 91 + } 92 + const url = remoteAssetUrl(repo, version, "styles.css"); 93 + try { 94 + const response = await this.pluginCache.fetch(url); 95 + return await response.text(); 96 + } catch (error) { 97 + if (error?.status === 404) return null; 98 + throw error; 99 + } 100 + } 101 + 81 102 // URLs that should be retained in the cache 82 103 // Local plugins have no cached URLs 83 104 async getCacheUrls(pluginId, version, repo) { ··· 87 108 return [ 88 109 remoteAssetUrl(repo, version, "manifest.json"), 89 110 remoteAssetUrl(repo, version, "main.js"), 111 + remoteAssetUrl(repo, version, "styles.css"), 90 112 ]; 91 113 } 92 114 }
+275
tests/unit/specs/pluginStylesLoader.test.js
··· 1 + import { TestSuite } from "../testSuite.js"; 2 + import { assert, assertEquals } from "../testHelpers.js"; 3 + import { 4 + PluginStylesLoader, 5 + validatePluginCss, 6 + } from "/js/plugins/pluginStylesLoader.js"; 7 + 8 + // JSDOM doesn't support constructible stylesheets or adoptedStyleSheets, so we 9 + // install minimal fakes that mirror just the surface the loader touches: 10 + // `new CSSStyleSheet()`, `replaceSync`, iterating `cssRules`, the at-rule 11 + // classes used in `instanceof` checks, and `document.adoptedStyleSheets`. 12 + 13 + class FakeStyle { 14 + constructor(props) { 15 + this._props = props; 16 + } 17 + *[Symbol.iterator]() { 18 + for (const prop of Object.keys(this._props)) yield prop; 19 + } 20 + getPropertyValue(prop) { 21 + return this._props[prop] ?? ""; 22 + } 23 + } 24 + 25 + class FakeRule { 26 + constructor({ style = null, cssRules = [] } = {}) { 27 + this.style = style; 28 + this.cssRules = cssRules; 29 + } 30 + } 31 + class FakeCSSImportRule extends FakeRule {} 32 + class FakeCSSFontFaceRule extends FakeRule {} 33 + class FakeCSSNamespaceRule extends FakeRule {} 34 + 35 + function parseFakeCss(text) { 36 + const rules = []; 37 + let index = 0; 38 + while (index < text.length) { 39 + while (index < text.length && /\s/.test(text[index])) index++; 40 + if (index >= text.length) break; 41 + if (text.startsWith("@import", index)) { 42 + const end = text.indexOf(";", index); 43 + rules.push(new FakeCSSImportRule()); 44 + index = end + 1; 45 + } else if (text.startsWith("@font-face", index)) { 46 + const end = text.indexOf("}", index); 47 + rules.push(new FakeCSSFontFaceRule()); 48 + index = end + 1; 49 + } else if (text.startsWith("@namespace", index)) { 50 + const end = text.indexOf(";", index); 51 + rules.push(new FakeCSSNamespaceRule()); 52 + index = end + 1; 53 + } else { 54 + const open = text.indexOf("{", index); 55 + const close = text.indexOf("}", open); 56 + const body = text.slice(open + 1, close); 57 + const props = {}; 58 + for (const declaration of body.split(";")) { 59 + const colon = declaration.indexOf(":"); 60 + if (colon < 0) continue; 61 + const prop = declaration.slice(0, colon).trim(); 62 + const value = declaration.slice(colon + 1).trim(); 63 + if (prop) props[prop] = value; 64 + } 65 + rules.push(new FakeRule({ style: new FakeStyle(props) })); 66 + index = close + 1; 67 + } 68 + } 69 + return rules; 70 + } 71 + 72 + class FakeCSSStyleSheet { 73 + constructor() { 74 + this.cssRules = []; 75 + } 76 + replaceSync(text) { 77 + this.cssRules = parseFakeCss(text); 78 + } 79 + } 80 + 81 + function stubCssEnv() { 82 + const originals = { 83 + CSSStyleSheet: globalThis.CSSStyleSheet, 84 + CSSImportRule: globalThis.CSSImportRule, 85 + CSSFontFaceRule: globalThis.CSSFontFaceRule, 86 + CSSNamespaceRule: globalThis.CSSNamespaceRule, 87 + }; 88 + globalThis.CSSStyleSheet = FakeCSSStyleSheet; 89 + globalThis.CSSImportRule = FakeCSSImportRule; 90 + globalThis.CSSFontFaceRule = FakeCSSFontFaceRule; 91 + globalThis.CSSNamespaceRule = FakeCSSNamespaceRule; 92 + 93 + const originalAdopted = Object.getOwnPropertyDescriptor( 94 + globalThis.document, 95 + "adoptedStyleSheets", 96 + ); 97 + let adopted = []; 98 + Object.defineProperty(globalThis.document, "adoptedStyleSheets", { 99 + configurable: true, 100 + get() { 101 + return adopted; 102 + }, 103 + set(value) { 104 + adopted = value; 105 + }, 106 + }); 107 + 108 + return { 109 + get adoptedStyleSheets() { 110 + return adopted; 111 + }, 112 + restore() { 113 + globalThis.CSSStyleSheet = originals.CSSStyleSheet; 114 + globalThis.CSSImportRule = originals.CSSImportRule; 115 + globalThis.CSSFontFaceRule = originals.CSSFontFaceRule; 116 + globalThis.CSSNamespaceRule = originals.CSSNamespaceRule; 117 + if (originalAdopted) { 118 + Object.defineProperty( 119 + globalThis.document, 120 + "adoptedStyleSheets", 121 + originalAdopted, 122 + ); 123 + } else { 124 + delete globalThis.document.adoptedStyleSheets; 125 + } 126 + }, 127 + }; 128 + } 129 + 130 + function expectThrow(fn, messageFragment) { 131 + let caught = null; 132 + try { 133 + fn(); 134 + } catch (error) { 135 + caught = error; 136 + } 137 + assert(caught, `expected ${fn} to throw`); 138 + assert( 139 + caught.message.includes(messageFragment), 140 + `expected error "${caught.message}" to include "${messageFragment}"`, 141 + ); 142 + } 143 + 144 + const t = new TestSuite("pluginStylesLoader"); 145 + 146 + t.describe("validatePluginCss", (it, { beforeEach, afterEach }) => { 147 + let env; 148 + beforeEach(() => { 149 + env = stubCssEnv(); 150 + }); 151 + afterEach(() => env.restore()); 152 + 153 + it("returns a stylesheet for valid CSS", () => { 154 + const sheet = validatePluginCss(".plugin { color: red; }"); 155 + assert(sheet instanceof FakeCSSStyleSheet); 156 + assertEquals(sheet.cssRules.length, 1); 157 + }); 158 + 159 + it("rejects @import rules", () => { 160 + expectThrow( 161 + () => validatePluginCss('@import url("evil.css");'), 162 + "@import not allowed", 163 + ); 164 + }); 165 + 166 + it("rejects @font-face rules", () => { 167 + expectThrow( 168 + () => 169 + validatePluginCss("@font-face { font-family: x; src: local('x'); }"), 170 + "@font-face not allowed", 171 + ); 172 + }); 173 + 174 + it("rejects @namespace rules", () => { 175 + expectThrow( 176 + () => validatePluginCss('@namespace svg url("http://example.test");'), 177 + "@namespace not allowed", 178 + ); 179 + }); 180 + 181 + it("rejects url() in declarations", () => { 182 + expectThrow( 183 + () => 184 + validatePluginCss( 185 + '.plugin { background: url("https://evil.test/x.png"); }', 186 + ), 187 + "disallowed url() in background", 188 + ); 189 + }); 190 + 191 + it("rejects image-set() in declarations", () => { 192 + expectThrow( 193 + () => 194 + validatePluginCss( 195 + '.plugin { background: image-set("a.png" 1x, "b.png" 2x); }', 196 + ), 197 + "disallowed url() in background", 198 + ); 199 + }); 200 + }); 201 + 202 + t.describe("PluginStylesLoader.mount", (it, { beforeEach, afterEach }) => { 203 + let env; 204 + beforeEach(() => { 205 + env = stubCssEnv(); 206 + }); 207 + afterEach(() => env.restore()); 208 + 209 + it("adopts a sheet for the plugin", () => { 210 + const loader = new PluginStylesLoader(); 211 + loader.mount("plugin-a", ".a { color: red; }"); 212 + assertEquals(env.adoptedStyleSheets.length, 1); 213 + }); 214 + 215 + it("appends sheets for multiple plugins without dropping prior ones", () => { 216 + const loader = new PluginStylesLoader(); 217 + loader.mount("plugin-a", ".a { color: red; }"); 218 + loader.mount("plugin-b", ".b { color: blue; }"); 219 + assertEquals(env.adoptedStyleSheets.length, 2); 220 + }); 221 + 222 + it("replaces a prior sheet when the same plugin mounts twice", () => { 223 + const loader = new PluginStylesLoader(); 224 + loader.mount("plugin-a", ".a { color: red; }"); 225 + const firstSheet = env.adoptedStyleSheets[0]; 226 + loader.mount("plugin-a", ".a { color: green; }"); 227 + assertEquals(env.adoptedStyleSheets.length, 1); 228 + assert(env.adoptedStyleSheets[0] !== firstSheet); 229 + }); 230 + 231 + it("throws and does not adopt when CSS is invalid", () => { 232 + const loader = new PluginStylesLoader(); 233 + expectThrow( 234 + () => loader.mount("plugin-a", '@import url("x.css");'), 235 + "@import not allowed", 236 + ); 237 + assertEquals(env.adoptedStyleSheets.length, 0); 238 + }); 239 + }); 240 + 241 + t.describe("PluginStylesLoader.unmount", (it, { beforeEach, afterEach }) => { 242 + let env; 243 + beforeEach(() => { 244 + env = stubCssEnv(); 245 + }); 246 + afterEach(() => env.restore()); 247 + 248 + it("removes only the named plugin's sheet", () => { 249 + const loader = new PluginStylesLoader(); 250 + loader.mount("plugin-a", ".a { color: red; }"); 251 + loader.mount("plugin-b", ".b { color: blue; }"); 252 + const sheetB = env.adoptedStyleSheets[1]; 253 + loader.unmount("plugin-a"); 254 + assertEquals(env.adoptedStyleSheets.length, 1); 255 + assert(env.adoptedStyleSheets[0] === sheetB); 256 + }); 257 + 258 + it("is a no-op for unknown plugin ids", () => { 259 + const loader = new PluginStylesLoader(); 260 + loader.mount("plugin-a", ".a { color: red; }"); 261 + loader.unmount("plugin-missing"); 262 + assertEquals(env.adoptedStyleSheets.length, 1); 263 + }); 264 + 265 + it("allows remounting a plugin after unmount", () => { 266 + const loader = new PluginStylesLoader(); 267 + loader.mount("plugin-a", ".a { color: red; }"); 268 + loader.unmount("plugin-a"); 269 + assertEquals(env.adoptedStyleSheets.length, 0); 270 + loader.mount("plugin-a", ".a { color: green; }"); 271 + assertEquals(env.adoptedStyleSheets.length, 1); 272 + }); 273 + }); 274 + 275 + await t.run();
+53 -1
tests/unit/specs/sourceProvider.test.js
··· 118 118 assertEquals(await provider.getCacheUrls("alpha__LOCAL"), []); 119 119 }); 120 120 121 + it("getStyles returns local styles.css text", async () => { 122 + stub = stubFetch(async () => ({ 123 + ok: true, 124 + status: 200, 125 + async text() { 126 + return "body{color:red}"; 127 + }, 128 + })); 129 + const provider = new SourceProvider(null); 130 + const styles = await provider.getStyles("alpha__LOCAL"); 131 + assertEquals(stub.calls[0].url, "/plugins-local/alpha__LOCAL/styles.css"); 132 + assertEquals(styles, "body{color:red}"); 133 + }); 134 + 135 + it("getStyles returns null when local styles.css is missing", async () => { 136 + stub = stubFetch(async () => ({ ok: false, status: 404 })); 137 + const provider = new SourceProvider(null); 138 + const styles = await provider.getStyles("alpha__LOCAL"); 139 + assertEquals(styles, null); 140 + }); 141 + 121 142 it("getLiveManifest delegates to getManifest for local plugins", async () => { 122 143 stub = stubFetch(async () => 123 144 jsonResponse({ id: "alpha", name: "Alpha", version: "9.9.9" }), ··· 193 214 assert(caught?.message.includes("does not match")); 194 215 }); 195 216 196 - it("getCacheUrls returns both manifest and main.js URLs", async () => { 217 + it("getCacheUrls includes manifest, main.js, and styles.css URLs", async () => { 197 218 const provider = new SourceProvider(null); 198 219 const urls = await provider.getCacheUrls("alpha", "1.2.3", "ow/alpha"); 199 220 assertEquals(urls, [ 200 221 "https://raw.githubusercontent.com/ow/alpha/1.2.3/manifest.json", 201 222 "https://raw.githubusercontent.com/ow/alpha/1.2.3/main.js", 223 + "https://raw.githubusercontent.com/ow/alpha/1.2.3/styles.css", 202 224 ]); 225 + }); 226 + 227 + it("getStyles fetches styles.css for remote plugins via the cache", async () => { 228 + const pluginCache = fakePluginCache(async () => ({ 229 + ok: true, 230 + status: 200, 231 + async text() { 232 + return "body{color:blue}"; 233 + }, 234 + })); 235 + const provider = new SourceProvider(pluginCache); 236 + const styles = await provider.getStyles("alpha", "1.0.0", "ow/alpha"); 237 + assertEquals( 238 + pluginCache.calls[0], 239 + "https://raw.githubusercontent.com/ow/alpha/1.0.0/styles.css", 240 + ); 241 + assertEquals(styles, "body{color:blue}"); 242 + }); 243 + 244 + it("getStyles returns null when remote styles.css 404s", async () => { 245 + const pluginCache = fakePluginCache(async () => { 246 + const error = new Error( 247 + "HTTP 404 https://raw.githubusercontent.com/ow/alpha/1.0.0/styles.css", 248 + ); 249 + error.status = 404; 250 + throw error; 251 + }); 252 + const provider = new SourceProvider(pluginCache); 253 + const styles = await provider.getStyles("alpha", "1.0.0", "ow/alpha"); 254 + assertEquals(styles, null); 203 255 }); 204 256 }); 205 257