this repo has no description
0
fork

Configure Feed

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

typescript fixes

alice d7cbea80 8a8c1508

+314 -277
+101 -81
src/background/service-worker.ts
··· 1 - // @ts-nocheck 2 - // TODO: Use 'browser' API with polyfill for cross-browser support 3 1 import { parseInput, resolveDidToHandle } from '../shared/transform'; 4 2 5 3 const DID_HANDLE_CACHE_KEY = 'didHandleCache'; 6 4 const MAX_CACHE_ENTRIES = 1000; // Maximum number of entries to keep in the cache 7 5 const CLEANUP_THRESHOLD = 1.2 * MAX_CACHE_ENTRIES; // Start cleanup when we're 20% over limit 8 6 7 + // Type definitions for cache entries 8 + interface CacheEntry { 9 + handle: string; 10 + lastAccessed: number; 11 + } 12 + 9 13 // Structure: { [did]: { handle: string, lastAccessed: number } } 10 14 11 15 /** 12 16 * Updates the cache with a new entry and ensures it doesn't exceed the maximum size 13 17 */ 14 18 // Use a simple LRU cache with Map for better performance 15 - let cache = new Map(); 19 + let cache = new Map<string, CacheEntry>(); 16 20 let isCacheDirty = false; 17 21 let cleanupScheduled = false; 18 22 19 23 // Load cache from storage on startup 20 - async function loadCache() { 24 + async function loadCache(): Promise<void> { 21 25 try { 22 - const result = await chrome.storage.local.get(DID_HANDLE_CACHE_KEY); 23 - const stored = result[DID_HANDLE_CACHE_KEY] || {}; 24 - // Keep only object-format entries (legacy strings are discarded) 25 - const entries = Object.entries(stored).filter( 26 - ([, entry]) => entry && typeof entry === 'object' 27 - ); 28 - cache = new Map(entries); 29 - } catch (error) { 26 + const result = await chrome.storage.local.get<Record<string, unknown>>(DID_HANDLE_CACHE_KEY); 27 + const stored = result[DID_HANDLE_CACHE_KEY]; 28 + const rawEntries = 29 + typeof stored === 'object' && stored != null ? Object.entries(stored as Record<string, unknown>) : []; 30 + const validEntries: [string, CacheEntry][] = []; 31 + for (const [key, val] of rawEntries) { 32 + if (typeof val === 'object' && val != null) { 33 + const e = val as Record<string, unknown>; 34 + if (typeof e.handle === 'string' && typeof e.lastAccessed === 'number') { 35 + validEntries.push([key, { handle: e.handle, lastAccessed: e.lastAccessed }]); 36 + } 37 + } 38 + } 39 + cache = new Map(validEntries); 40 + } catch (error: unknown) { 30 41 console.error('Failed to load cache:', error); 31 42 } 32 43 } 33 44 34 45 // Save cache to storage, but debounce to prevent frequent writes 35 - let saveTimeout = null; 36 - async function saveCache() { 37 - isCacheDirty = true; 46 + let saveTimeout: ReturnType<typeof setTimeout> | null = null; 47 + 48 + /** 49 + * Performs the actual save and cache-quota handling 50 + */ 51 + async function _doSave(resolve: () => void): Promise<void> { 52 + if (isCacheDirty) { 53 + try { 54 + await chrome.storage.local.set({ 55 + [DID_HANDLE_CACHE_KEY]: Object.fromEntries(cache), 56 + }); 57 + isCacheDirty = false; 58 + } catch (error: unknown) { 59 + console.error('Failed to save cache:', error); 60 + if (error instanceof Error && error.message.includes('QUOTA_BYTES')) { 61 + await clearOldCacheEntries(0.5); 62 + void saveCache().then(resolve); 63 + return; 64 + } 65 + } 66 + } 67 + resolve(); 68 + } 38 69 70 + async function saveCache(): Promise<void> { 71 + isCacheDirty = true; 39 72 if (saveTimeout) clearTimeout(saveTimeout); 40 - 41 - // Debounce saves to prevent excessive writes 42 - return new Promise((resolve) => { 43 - saveTimeout = setTimeout(async () => { 44 - if (isCacheDirty) { 45 - try { 46 - await chrome.storage.local.set({ 47 - [DID_HANDLE_CACHE_KEY]: Object.fromEntries(cache), 48 - }); 49 - isCacheDirty = false; 50 - } catch (error) { 51 - console.error('Failed to save cache:', error); 52 - if (error.message.includes('QUOTA_BYTES')) { 53 - await clearOldCacheEntries(0.5); 54 - return saveCache().then(resolve); 55 - } 56 - } 57 - } 58 - resolve(); 59 - }, 1000); // 1s debounce 73 + return new Promise<void>((resolve) => { 74 + saveTimeout = setTimeout(() => { 75 + void _doSave(resolve); 76 + }, 1000); // debounce 60 77 }); 61 78 } 62 79 63 - async function updateCache(did, handle) { 80 + async function updateCache(did: string, handle: string): Promise<void> { 64 81 try { 65 82 // Update the in-memory cache 66 83 cache.set(did, { handle, lastAccessed: Date.now() }); ··· 70 87 cleanupScheduled = true; 71 88 // Run cleanup on the next tick to avoid blocking the UI 72 89 setTimeout(() => { 73 - cleanupCache().finally(() => { 90 + void cleanupCache().finally(() => { 74 91 cleanupScheduled = false; 75 92 }); 76 93 }, 0); ··· 78 95 79 96 // Schedule a save to storage 80 97 await saveCache(); 81 - } catch (error) { 98 + } catch (error: unknown) { 82 99 console.error('Failed to update cache:', error); 83 100 } 84 101 } ··· 87 104 * Clears the oldest entries from the cache 88 105 * @param {number} ratio - Fraction of the cache to clear (0-1) 89 106 */ 90 - async function cleanupCache() { 107 + async function cleanupCache(): Promise<void> { 91 108 if (cache.size <= MAX_CACHE_ENTRIES) return; 92 109 93 110 try { ··· 102 119 103 120 // Save the cleaned-up cache 104 121 await saveCache(); 105 - } catch (error) { 122 + } catch (error: unknown) { 106 123 console.error('Failed to clean up cache:', error); 107 124 } 108 125 } 109 126 110 127 // For backward compatibility with the existing code 111 - async function clearOldCacheEntries(ratio = 0.5) { 128 + async function clearOldCacheEntries(ratio = 0.5): Promise<void> { 112 129 if (cache.size === 0) return; 113 130 114 131 try { ··· 118 135 119 136 cache = new Map(toKeep); 120 137 await saveCache(); 121 - } catch (error) { 138 + } catch (error: unknown) { 122 139 console.error('Failed to clear old cache entries:', error); 123 140 } 124 141 } ··· 126 143 // Initialize the cache when the service worker starts 127 144 const cacheLoaded = loadCache().catch(console.error); 128 145 146 + // Message types for service worker comms 147 + type SWMessage = 148 + | { type: 'UPDATE_CACHE'; did: string; handle: string } 149 + | { type: 'GET_HANDLE'; did: string } 150 + | { type: 'CLEAR_CACHE' }; 151 + 129 152 // Handle messages from the popup 130 - chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 131 - if (request.type === 'UPDATE_CACHE' && request.did && request.handle) { 132 - updateCache(request.did, request.handle) 133 - .then(() => sendResponse({ success: true })) 134 - .catch((error) => { 153 + chrome.runtime.onMessage.addListener((request: SWMessage, sender, sendResponse): boolean => { 154 + // UPDATE_CACHE 155 + if (request.type === 'UPDATE_CACHE' && typeof request.did === 'string' && typeof request.handle === 'string') { 156 + void updateCache(request.did, request.handle) 157 + .then(() => { 158 + sendResponse({ success: true }); 159 + }) 160 + .catch((error: unknown) => { 135 161 console.error('Failed to update cache via message:', error); 136 - sendResponse({ success: false, error: error.message }); 162 + sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) }); 137 163 }); 138 164 return true; // Indicates async response 139 165 } 140 - // Provide handle for popup: return cached or resolve if missing 141 - if (request.type === 'GET_HANDLE' && request.did) { 142 - (async () => { 166 + // GET_HANDLE 167 + if (request.type === 'GET_HANDLE' && typeof request.did === 'string') { 168 + void (async () => { 143 169 await cacheLoaded; 144 170 try { 145 - let entry = cache.get(request.did); 171 + const entry = cache.get(request.did); 146 172 if (entry) { 147 173 entry.lastAccessed = Date.now(); 148 - saveCache().catch(console.error); 174 + void saveCache().catch(console.error); 149 175 sendResponse({ handle: entry.handle }); 150 176 return; 151 177 } 152 178 const h = await resolveDidToHandle(request.did); 153 179 if (h) await updateCache(request.did, h); 154 - sendResponse({ handle: h || null }); 155 - } catch (e) { 180 + sendResponse({ handle: h ?? null }); 181 + } catch (e: unknown) { 156 182 console.error('GET_HANDLE error', e); 157 183 sendResponse({ handle: null }); 158 184 } 159 185 })(); 160 186 return true; 161 187 } 188 + // CLEAR_CACHE 162 189 if (request.type === 'CLEAR_CACHE') { 163 190 // Clear the in-memory cache 164 191 cache.clear(); 165 192 // Clear the storage 166 - chrome.storage.local 193 + void chrome.storage.local 167 194 .remove(DID_HANDLE_CACHE_KEY) 168 195 .then(() => { 169 196 sendResponse({ success: true }); 170 197 }) 171 - .catch((error) => { 198 + .catch((error: unknown) => { 172 199 console.error('Failed to clear storage cache:', error); 173 - sendResponse({ 174 - success: false, 175 - error: error.message, 176 - }); 200 + sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) }); 177 201 }); 178 202 179 203 return true; // Keep the message channel open for the async response ··· 183 207 return false; 184 208 }); 185 209 186 - chrome.tabs.onUpdated.addListener(async (_, info, tab) => { 210 + chrome.tabs.onUpdated.addListener((_tabId, info, tab) => { 187 211 if (info.status !== 'complete' || !tab.url) return; 212 + void (async () => { 213 + try { 214 + const data = await parseInput(tab.url!); 215 + if (!data?.did || data.handle) return; 188 216 189 - try { 190 - const data = await parseInput(tab.url); 191 - if (!data?.did || data.handle) return; 217 + const cached = cache.get(data.did); 218 + if (cached) { 219 + cached.lastAccessed = Date.now(); 220 + void saveCache().catch(console.error); 221 + return; 222 + } 192 223 193 - // Check in-memory cache first 194 - const cached = cache.get(data.did); 195 - if (cached) { 196 - // Update lastAccessed in memory and schedule a save 197 - cached.lastAccessed = Date.now(); 198 - saveCache().catch(console.error); 199 - return; 200 - } 201 - 202 - // Not in cache, resolve and add to cache 203 - const h = await resolveDidToHandle(data.did); 204 - if (h) { 205 - await updateCache(data.did, h); 224 + const h = await resolveDidToHandle(data.did); 225 + if (h) await updateCache(data.did, h); 226 + } catch (error: unknown) { 227 + console.error('SW error', error); 206 228 } 207 - } catch (e) { 208 - console.error('SW error', e); 209 - } 229 + })(); 210 230 });
+102 -82
src/popup/popup.ts
··· 1 - import '../shared/transform'; 2 - // @ts-nocheck 3 - // TODO: Use 'browser' API with polyfill for cross-browser support 1 + import { parseInput, buildDestinations, TransformInfo } from '../shared/transform'; 2 + 3 + // Local type for list items 4 + interface Destination { 5 + url: string; 6 + label: string; 7 + } 4 8 5 9 /** 6 10 * Main entry for popup script. Runs on DOMContentLoaded. 7 11 */ 8 - document.addEventListener('DOMContentLoaded', async () => { 9 - const list = document.getElementById('dest'); 10 - const emptyBtn = document.getElementById('emptyCacheBtn'); 12 + document.addEventListener('DOMContentLoaded', () => { 13 + void (async () => { 14 + const list = document.getElementById('dest') as HTMLUListElement; 15 + const emptyBtn = document.getElementById('emptyCacheBtn') as HTMLButtonElement; 11 16 12 - // Close popup when a destination link is clicked (Firefox MV3 does not auto-close) 13 - list?.addEventListener('click', (e) => { 14 - const anchor = (e.target as HTMLElement).closest('a'); 15 - if (anchor && 'href' in anchor) { 16 - e.preventDefault(); 17 - e.stopPropagation(); 18 - chrome.tabs.create({ url: anchor.href }); 19 - window.close(); 20 - } 21 - }); 17 + // Close popup when a destination link is clicked (Firefox MV3 does not auto-close) 18 + list.addEventListener('click', (e: MouseEvent) => { 19 + const anchor = (e.target as HTMLElement).closest('a'); 20 + if (anchor?.href) { 21 + e.preventDefault(); 22 + e.stopPropagation(); 23 + void chrome.tabs.create({ url: anchor.href }); 24 + window.close(); 25 + } 26 + }); 22 27 23 - const showStatus = (msg) => (list.innerHTML = `<li>${msg}</li>`); 24 - const createItem = ({ url, label }) => ` 28 + const showStatus = (msg: string): void => { 29 + list.innerHTML = `<li>${msg}</li>`; 30 + }; 31 + const createItem = ({ url, label }: Destination): string => ` 25 32 <li> 26 33 <a href="${url}" target="_blank" rel="noopener noreferrer" 27 34 style="display:block;padding:6px 8px;border:1px solid #ccc;border-radius:6px;background:#fafafa;text-decoration:none;color:inherit;font-size:14px;"> 28 35 ${label} 29 36 </a> 30 37 </li>`; 31 - const render = (ds) => 32 - ds && ds.length ? (list.innerHTML = ds.map(createItem).join('')) : showStatus('No actions available'); 33 - 34 - const rawParam = new URLSearchParams(location.search).get('payload'); 35 - let raw = rawParam || (await chrome.tabs.query({ active: true, currentWindow: true }))[0].url; 38 + const render = (ds: Destination[]): void => { 39 + if (ds.length) { 40 + list.innerHTML = ds.map(createItem).join(''); 41 + } else { 42 + showStatus('No actions available'); 43 + } 44 + }; 36 45 37 - if (!window.WormholeTransform?.parseInput) { 38 - showStatus('Error: transform not loaded'); 39 - return; // Exit if critical component is missing 40 - } 41 - 42 - const info = await window.WormholeTransform.parseInput(raw); 43 - if (!info?.did && !info?.atUri) { 44 - showStatus('No DID or at:// found'); 45 - return; // Exit if no relevant data 46 - } 46 + // Determine input: payload param or active tab URL 47 + const payload = new URLSearchParams(location.search).get('payload'); 48 + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 49 + const activeUrl = tabs[0]?.url ?? ''; 50 + const raw: string = payload ?? activeUrl; 51 + if (!raw) { 52 + showStatus('No URL or payload provided'); 53 + return; 54 + } 47 55 48 - let ds = window.WormholeTransform.buildDestinations(info); // Initial build (might be without handle) 49 - render(ds); // Initial render 56 + const info: TransformInfo | null = await parseInput(raw); 57 + if (!info?.did && !info?.atUri) { 58 + showStatus('No DID or at:// found'); 59 + return; // Exit if no relevant data 60 + } 50 61 62 + let ds = buildDestinations(info); // Initial build 63 + render(ds); // Initial render 51 64 52 - if (info.did && !info.handle) { 53 - let handleToUse: string | null = null; 54 - let errorStatusWasSet = false; // Flag to track if an error message was shown 65 + if (info.did && !info.handle) { 66 + let handleToUse: string | null = null; 67 + let errorStatusWasSet = false; // Flag to track if an error message was shown 55 68 56 - // Ask SW for a handle (from cache or resolved) 57 - showStatus('Resolving...'); 58 - try { 59 - const response: { handle: string | null } = await new Promise((resolve) => 60 - chrome.runtime.sendMessage({ type: 'GET_HANDLE', did: info.did }, resolve) 61 - ); 62 - handleToUse = response.handle; 63 - } catch (err) { 64 - console.error('GET_HANDLE error', err); 65 - showStatus('Error resolving'); 66 - errorStatusWasSet = true; 67 - } 69 + // Ask SW for a handle (from cache or resolved) 70 + showStatus('Resolving...'); 71 + try { 72 + const response = await new Promise<{ handle: string | null }>((resolve, reject) => { 73 + chrome.runtime.sendMessage({ type: 'GET_HANDLE', did: info.did }, (res) => { 74 + if (chrome.runtime.lastError) { 75 + reject(new Error(chrome.runtime.lastError.message)); 76 + } else { 77 + resolve(res as { handle: string | null }); 78 + } 79 + }); 80 + }); 81 + handleToUse = response.handle; 82 + } catch (err: unknown) { 83 + console.error('GET_HANDLE error', err); 84 + showStatus('Error resolving'); 85 + errorStatusWasSet = true; 86 + } 68 87 69 - // After attempting to get handle from cache or by fetching: 70 - if (handleToUse) { 71 - info.handle = handleToUse; 72 - ds = window.WormholeTransform.buildDestinations(info); // Re-build destinations with the handle 73 - render(ds); // Re-render the list 74 - } else { 75 - // Handle was not obtained. An error status might have already been set. 76 - // If the list is still empty and no explicit error status was set, show "No actions available". 77 - if (!ds.length && !errorStatusWasSet) { 78 - showStatus('No actions available'); 88 + // After attempting to get handle from cache or by fetching: 89 + if (handleToUse) { 90 + info.handle = handleToUse; 91 + ds = buildDestinations(info); // Re-build destinations with the handle 92 + render(ds); // Re-render the list 93 + } else { 94 + // Handle was not obtained. An error status might have already been set. 95 + // If the list is still empty and no explicit error status was set, show "No actions available". 96 + if (!ds.length && !errorStatusWasSet) { 97 + showStatus('No actions available'); 98 + } 79 99 } 80 100 } 81 - } 82 101 83 - if (emptyBtn) { 84 - emptyBtn.addEventListener('click', async (e) => { 102 + emptyBtn.addEventListener('click', (e) => { 85 103 e.preventDefault(); 86 104 e.stopPropagation(); 87 105 ··· 89 107 emptyBtn.textContent = 'Working...'; 90 108 emptyBtn.disabled = true; 91 109 92 - try { 110 + void (async () => { 93 111 await chrome.storage.local.remove('didHandleCache'); 94 112 95 - await new Promise((resolve, reject) => { 96 - chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' }, (msgResponse) => { 113 + await new Promise<void>((resolve, reject) => { 114 + chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' }, (rawRes: unknown) => { 115 + const res = rawRes as { success: boolean; error?: string }; 97 116 if (chrome.runtime.lastError) { 98 117 console.error('Runtime error sending message:', chrome.runtime.lastError); 99 - return reject(new Error(chrome.runtime.lastError.message)); 118 + reject(new Error(chrome.runtime.lastError.message)); 119 + return; 100 120 } 101 - if (msgResponse && msgResponse.success) { 102 - resolve(msgResponse); 121 + if (res.success) { 122 + resolve(); 103 123 } else { 104 - reject(new Error(msgResponse?.error || 'Unknown error from service worker')); 124 + reject(new Error(res.error ?? 'Unknown error from service worker')); 105 125 } 106 126 }); 107 127 }); 108 128 emptyBtn.textContent = 'Cleared'; 109 - } catch (error) { 110 - console.error('Failed to clear cache:', error); 111 - emptyBtn.textContent = 'Error'; 112 - } finally { 113 - setTimeout(() => { 114 - emptyBtn.textContent = originalText; 115 - emptyBtn.disabled = false; 116 - }, 1500); 117 - } 129 + })() 130 + .catch((error: unknown) => { 131 + console.error('Failed to clear cache:', error); 132 + emptyBtn.textContent = 'Error'; 133 + }) 134 + .finally(() => { 135 + setTimeout(() => { 136 + emptyBtn.textContent = originalText; 137 + emptyBtn.disabled = false; 138 + }, 1500); 139 + }); 118 140 }); 119 - } else { 120 - console.error('emptyCacheBtn NOT FOUND even within DOMContentLoaded.'); 121 - } 141 + })(); 122 142 }); 123 143 124 144 console.log('popup.js script finished initial global execution.');
+78 -93
src/shared/transform.ts
··· 4 4 import { NSID_SHORTCUTS } from './constants'; 5 5 6 6 /** 7 + * Standardized info returned from transform functions. 8 + */ 9 + export interface TransformInfo { 10 + atUri: string; 11 + did: string; 12 + handle: string | null; 13 + rkey?: string; 14 + nsid?: string; 15 + bskyAppPath: string; 16 + } 17 + 18 + /** 7 19 * Parses a raw input string (URL, DID, handle) and returns canonical info. 8 20 */ 9 - export async function parseInput(raw: string): Promise<any> { 21 + export async function parseInput(raw: string): Promise<TransformInfo | null> { 10 22 if (!raw) return null; 11 - let str = decodeURIComponent(raw.trim()); 23 + const str = decodeURIComponent(raw.trim()); 12 24 if (!str.startsWith('http')) { 13 25 return await canonicalize(str); 14 26 } 15 27 16 - const atMatch = str.match(/at:\/\/[\w:.\-/]+/); 28 + const atMatch = /at:\/\/[\w:.\-/]+/.exec(str); 17 29 if (atMatch) { 18 30 return await canonicalize(atMatch[0]); 19 31 } ··· 36 48 } 37 49 38 50 const qParam = url.searchParams.get('q'); 39 - if (qParam && qParam.startsWith('did:')) { 51 + if (qParam?.startsWith('did:')) { 40 52 return await canonicalize(qParam); 41 53 } 42 54 43 55 const parts = str.split(/[/?#]/); 44 56 for (let i = 0; i < parts.length; i++) { 45 57 const p = parts[i]; 46 - if (p.startsWith('did:') || (p.includes('.') && parts[i - 1] === 'profile')) { 58 + if (p.startsWith('did:') || (p.includes('.') && parts[i - 1]?.toLowerCase() === 'profile')) { 47 59 const rest = parts.slice(i + 1).join('/'); 48 - return await canonicalize(p + (rest ? '/' + rest : '')); 60 + // Include slash before rest path 61 + const fragment = rest ? `${p}/${rest}` : p; 62 + return await canonicalize(fragment); 49 63 } 50 64 } 51 65 } catch (error) { ··· 58 72 /** 59 73 * Canonicalizes an input fragment into a standard info object. 60 74 */ 61 - export async function canonicalize(fragment: string): Promise<any> { 62 - let f = fragment.replace(/^at:\/([^/])/, 'at://$1'); 75 + export async function canonicalize(fragment: string): Promise<TransformInfo | null> { 76 + let f = fragment.replace(/^at:\/\/([^/])/, 'at://$1'); 63 77 if (!f.startsWith('at://')) f = 'at://' + f; 64 78 const [, idAndRest] = f.split('at://'); 65 79 const [idPart, ...restParts] = idAndRest.split('/'); ··· 79 93 const [nsid, rkey] = pathRest.split('/').filter(Boolean); 80 94 81 95 let bskyAppPath = ''; 82 - const acct = handle || did; 96 + const acct = handle ?? did; 83 97 if (acct) { 84 98 bskyAppPath = `/profile/${acct}`; 85 99 if (nsid && rkey) { ··· 93 107 if (!did) return null; 94 108 95 109 return { 96 - atUri: 'at://' + (did || handle) + (pathRest ? '/' + pathRest : ''), 110 + atUri: `at://${did}${pathRest ? `/${pathRest}` : ''}`, 97 111 did, 98 112 handle, 99 113 rkey, ··· 102 116 }; 103 117 } 104 118 119 + // Type guard for JSON 120 + function isRecord(x: unknown): x is Record<string, unknown> { 121 + return typeof x === 'object' && x !== null; 122 + } 123 + 124 + // Safely parse JSON and ensure it's an object 125 + async function safeJson<T extends Record<string, unknown>>(resp: Response): Promise<T | null> { 126 + if (!resp.ok) return null; 127 + const raw = (await resp.json()) as unknown; 128 + return isRecord(raw) ? (raw as T) : null; 129 + } 130 + 105 131 /** 106 132 * Resolves a handle to a DID, using the Bluesky API or did:web. 107 133 */ 108 134 export async function resolveHandleToDid(handle: string): Promise<string | null> { 109 - if (typeof handle === 'string' && handle.startsWith('did:web:')) { 135 + if (handle.startsWith('did:web:')) { 110 136 const parts = handle.split(':'); 111 137 if (parts.length === 3) { 112 138 try { 113 139 const resp = await fetch(`https://${parts[2]}/.well-known/did.json`); 114 - if (resp?.ok) { 115 - const { id } = await resp.json(); 116 - return id || handle; 117 - } 140 + const data = await safeJson<{ id?: string }>(resp); 141 + return data?.id ?? handle; 118 142 } catch { 119 143 /* ignore */ 120 144 } 121 - return handle; 122 145 } 123 146 return handle; 124 147 } ··· 126 149 const resp = await fetch( 127 150 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 128 151 ); 129 - if (resp.ok) { 130 - const { did: resolved } = await resp.json(); 131 - return resolved || null; 132 - } 152 + const data = await safeJson<{ did?: string }>(resp); 153 + return data?.did ?? null; 133 154 } catch { 134 155 /* ignore */ 135 156 } ··· 137 158 } 138 159 139 160 /** 161 + * Resolves a DID to its handle, if possible. 162 + */ 163 + export async function resolveDidToHandle(did: string): Promise<string | null> { 164 + if (!did) return null; 165 + if (did.startsWith('did:plc:')) { 166 + try { 167 + const resp = await fetch(`https://plc.directory/${encodeURIComponent(did)}`); 168 + const data = await safeJson<{ alsoKnownAs?: unknown }>(resp); 169 + const h = data ? _extractHandleFromAlsoKnownAs(data.alsoKnownAs) : null; 170 + if (h) return h; 171 + } catch { 172 + /* ignore */ 173 + } 174 + } 175 + 176 + if (did.startsWith('did:web:')) { 177 + const url = _getDidWebWellKnownUrl(did); 178 + try { 179 + const resp = await fetch(url); 180 + const data = await safeJson<{ alsoKnownAs?: unknown }>(resp); 181 + const h = data ? _extractHandleFromAlsoKnownAs(data.alsoKnownAs) : null; 182 + if (h) return h; 183 + } catch { 184 + /* ignore */ 185 + } 186 + 187 + return decodeURIComponent(did.substring('did:web:'.length).split('#')[0]); 188 + } 189 + return null; 190 + } 191 + 192 + /** 140 193 * Extracts a handle from an alsoKnownAs array (used in did:web). 141 194 */ 142 - function _extractHandleFromAlsoKnownAs(alsoKnownAs: any): string | null { 195 + function _extractHandleFromAlsoKnownAs(alsoKnownAs: unknown): string | null { 143 196 if (Array.isArray(alsoKnownAs)) { 144 197 for (const aka of alsoKnownAs) { 145 198 if (typeof aka === 'string' && aka.startsWith('at://')) { ··· 164 217 if (parts.length > 1) { 165 218 path = '/' + parts.slice(1).join('/'); 166 219 } 167 - if (path && path.endsWith('/')) { 220 + if (path.endsWith('/')) { 168 221 path = path.slice(0, -1); 169 222 } 170 223 return `https://${hostAndPort}${path}/.well-known/did.json`; 171 224 } 172 225 173 226 /** 174 - * Resolves a DID to its handle, if possible. 175 - */ 176 - export async function resolveDidToHandle(did: string): Promise<string | null> { 177 - if (!did || typeof did !== 'string') return null; 178 - if (did.startsWith('did:plc:')) { 179 - try { 180 - const resp = await fetch(`https://plc.directory/${encodeURIComponent(did)}`); 181 - if (resp.ok) { 182 - const { alsoKnownAs } = await resp.json(); 183 - const h = _extractHandleFromAlsoKnownAs(alsoKnownAs); 184 - if (h) return h; 185 - } 186 - } catch { 187 - /* ignore */ 188 - } 189 - try { 190 - const resp = await fetch( 191 - `https://public.api.bsky.app/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`, 192 - ); 193 - if (resp.ok) { 194 - const { handle } = await resp.json(); 195 - if (handle) return handle; 196 - } 197 - } catch { 198 - /* ignore */ 199 - } 200 - return null; 201 - } 202 - if (did.startsWith('did:web:')) { 203 - const url = _getDidWebWellKnownUrl(did); 204 - try { 205 - const resp = await fetch(url); 206 - if (resp.ok) { 207 - const { alsoKnownAs } = await resp.json(); 208 - const h = _extractHandleFromAlsoKnownAs(alsoKnownAs); 209 - if (h) return h; 210 - } 211 - } catch { 212 - /* ignore */ 213 - } 214 - try { 215 - const resp = await fetch( 216 - `https://public.api.bsky.app/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`, 217 - ); 218 - if (resp.ok) { 219 - const { handle } = await resp.json(); 220 - if (handle) return handle; 221 - } 222 - } catch { 223 - /* ignore */ 224 - } 225 - return decodeURIComponent(did.substring('did:web:'.length).split('#')[0]); 226 - } 227 - return null; 228 - } 229 - 230 - /** 231 227 * Builds a list of destination link objects from canonical info. 232 228 */ 233 - export function buildDestinations(info: any): Array<{ label: string; url: string }> { 229 + export function buildDestinations(info: TransformInfo): { label: string; url: string }[] { 234 230 const { atUri, did, handle, rkey, bskyAppPath } = info; 235 - const isDidWeb = did && did.startsWith('did:web:'); 231 + const isDidWeb = did.startsWith('did:web:'); 236 232 return [ 237 233 { label: '🦌 deer.social', url: `https://deer.social${bskyAppPath}` }, 238 234 { label: '🦋 bsky.app', url: `https://bsky.app${bskyAppPath}` }, ··· 284 280 }, 285 281 ] 286 282 : []), 287 - ].filter((d) => !!d && !!d.url); 288 - } 289 - 290 - // Attach to window for popup usage (browser only) 291 - if (typeof window !== 'undefined') { 292 - (window as any).WormholeTransform = { 293 - parseInput, 294 - canonicalize, 295 - resolveHandleToDid, 296 - resolveDidToHandle, 297 - buildDestinations, 298 - }; 283 + ].filter((d) => Boolean(d.url)); 299 284 }
+22 -15
tests/transform.test.ts
··· 56 56 }, 57 57 ]; 58 58 59 - // Patch fetch with preconnect property to satisfy Bun's global type 60 - const fetchWithPreconnect = (url: URL | RequestInfo) => { 61 - const mock = fetchMockConfigs.find((m) => m.condition(url.toString())); 59 + // Define typed fetch mock with preconnect 60 + type FetchFn = ((url: URL | RequestInfo) => Promise<Response>) & { preconnect: () => void }; 61 + const fetchWithPreconnect = ((url: URL | RequestInfo) => { 62 + // Normalize URL to string 63 + const urlStr = 64 + typeof url === 'string' ? url 65 + : url instanceof URL ? url.href 66 + : ''; 67 + const mock = fetchMockConfigs.find((m) => m.condition(urlStr)); 62 68 if (mock) { 63 69 return mock.response(); 64 70 } 65 - console.warn(`Unhandled fetch mock for URL: ${url}`); 71 + console.warn('Unhandled fetch mock for URL: ' + urlStr); 66 72 return Promise.resolve( 67 73 new Response(JSON.stringify({}), { 68 74 status: 500, 69 75 headers: { 'Content-Type': 'application/json' }, 70 76 }), 71 77 ); 78 + }) as FetchFn; 79 + fetchWithPreconnect.preconnect = () => { 80 + /* no-op */ 72 81 }; 73 - (fetchWithPreconnect as any).preconnect = () => {}; 74 - // Use 'any' to satisfy Bun's global.fetch type 75 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 76 - (global as any).fetch = fetchWithPreconnect; 82 + globalThis.fetch = fetchWithPreconnect; 77 83 78 84 async function run() { 79 85 let passed = 0, ··· 84 90 assert.deepStrictEqual(actual, expected); 85 91 console.log(`PASS: ${name}`); 86 92 passed++; 87 - // eslint-disable-next-line no-unused-vars 88 - } catch (e) { 93 + } catch { 89 94 console.error(`FAIL: ${name}`); 95 + console.log(`EXPECTED: ${JSON.stringify(expected, null, 2)}`); 96 + console.log(`ACTUAL: ${JSON.stringify(actual, null, 2)}`); 90 97 failed++; 91 98 } 92 99 } ··· 170 177 try { 171 178 const out = await parseInput(test.input); 172 179 check(test.name, out, test.expected); 173 - } catch (e) { 174 - console.log(`FAIL: ${test.name} (exception)`, e); 180 + } catch { 181 + console.log(`FAIL: ${test.name} (exception)`); 175 182 failed++; 176 183 } 177 184 } ··· 204 211 try { 205 212 const out = await resolveHandleToDid(test.input); 206 213 check(test.name, out, test.expected); 207 - } catch (e) { 208 - console.log(`FAIL: ${test.name} (exception)`, e); 214 + } catch { 215 + console.log(`FAIL: ${test.name} (exception)`); 209 216 failed++; 210 217 } 211 218 } ··· 214 221 process.exit(failed ? 1 : 0); 215 222 } 216 223 217 - run(); 224 + void run();
+1 -1
tsconfig.json
··· 20 20 "@background/*": ["src/background/*"] 21 21 } 22 22 }, 23 - "include": ["src", "tests"], 23 + "include": ["src", "tests", "vite.config.ts"], 24 24 "exclude": ["node_modules", "dist"] 25 25 }
+10 -5
vite.config.ts
··· 4 4 import fs from 'fs'; 5 5 import path from 'path'; 6 6 7 + // Define minimal manifest interface for JSON parsing 8 + interface DevManifest { 9 + background?: { scripts?: string[] }; 10 + } 11 + 7 12 export default defineConfig({ 8 13 plugins: [ 9 14 crx({ manifest }), 10 15 { 11 16 name: 'firefox-mv3-fix', 12 - writeBundle() { 17 + writeBundle(): void { 13 18 // Firefox MV3 requires a background.scripts entry 14 19 // https://stackoverflow.com/a/78088358 15 20 const mf = path.resolve(process.cwd(), 'dist/manifest.json'); 16 21 if (fs.existsSync(mf)) { 17 - const m = JSON.parse(fs.readFileSync(mf, 'utf-8')); 22 + const m = JSON.parse(fs.readFileSync(mf, 'utf-8')) as DevManifest; 18 23 if (m.background && !m.background.scripts) { 19 24 m.background.scripts = ['service-worker-loader.js']; 20 25 fs.writeFileSync(mf, JSON.stringify(m, null, 2)); 21 26 console.log('✔️ Injected background.scripts for Firefox MV3'); 22 27 } 23 28 } 24 - } 25 - } 26 - ] 29 + }, 30 + }, 31 + ], 27 32 });