this repo has no description
0
fork

Configure Feed

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

Simplify (#15)

* Simplify logging, popup UI, and proactive caching

* Clean up remaining files

authored by

Alice and committed by
GitHub
159609fd 0425ef15

+537 -1100
+65
AGENTS.md
··· 1 + # AGENTS.md 2 + 3 + _Last updated: 2025-11-15_ 4 + 5 + ## Purpose 6 + 7 + - Replaces `CLAUDE.md` as the single source of truth for coding agents. 8 + - Summarizes the guardrails that actually keep this MV3 extension healthy, plus the current review backlog. 9 + 10 + ## Core Guardrails 11 + 12 + - Never suppress lint/type errors (`eslint-disable`, `@ts-ignore`, etc. are banned). Fix root causes instead. 13 + - Keep changes scoped and well-explained; when work is non-trivial create a short plan (see Planning below) before editing code. 14 + - Communicate clearly and directly—no fluff, no praise. 15 + - Do not remove functionality or tests to “make things pass”. Prefer incremental, reversible changes. 16 + 17 + ## Required Validation Commands 18 + 19 + Run **all** of these before handing work off: 20 + 21 + 1. `bun run format` 22 + 2. `bun run lint` 23 + 3. `bun run typecheck` 24 + 4. `bun run test` 25 + 5. `bun run build:dev` 26 + 6. `npx --yes web-ext lint -s dist --output json` (run after the build step; Firefox store rejects bundles that fail this) 27 + 28 + ## Planning & Documentation 29 + 30 + - For anything larger than a quick fix, sketch a numbered plan (multi-step, no single-item plans) and put it in `planning/` as `YYYY-MM-DD-feature.md`. Update progress there as you work. 31 + - Keep commit-sized work units; document tricky decisions inline with short comments when the code isn’t self-explanatory. 32 + 33 + ## Architecture Cheatsheet _(2025-11-15)_ 34 + 35 + - `src/shared/logging.ts` → tiny helper that no-ops in production; replaces the old `Debug` class. 36 + - `src/shared/parser.ts` ➜ parses raw input/URLs and now owns the service-specific parsing helpers (no cross-module hop). 37 + - `src/shared/canonicalizer.ts` ➜ normalizes fragments into `TransformInfo`. 38 + - `src/shared/resolver.ts` + `src/shared/cache.ts` ➜ handle↔DID resolution with a debounced `DidHandleCache` and inlined retry logic. 39 + - `src/shared/services.ts` ➜ service registry + destination builders; parsing helpers moved out. 40 + - `src/shared/options.ts` ➜ minimal options API (`showEmojis`, `strictMode`, `showCacheDebug`) with listener helpers. 41 + - `src/background/service-worker.ts` ➜ message router plus a lightweight `tabs.onUpdated` listener that precaches DID/handle pairs for any URL `parseInput` understands (all supported services). 42 + - `src/popup/*` ➜ DOM-only rendering (no `innerHTML`), Firefox theme via CSS variables, inline cache debug panel. 43 + - `src/options/*` ➜ simple UI with three toggles; errors revert to the last known good state. 44 + - Builds via Vite + `@crxjs/vite-plugin`; remember to run all validation commands listed above. 45 + 46 + ## Release / QA Backlog (from 2025-11-15 review) 47 + 48 + Update this checklist as items ship; keep it honest. 49 + 50 + - [x] **Popup DOM sanitization** (`src/popup/popup.ts:176-193`): Rendering flow writes unsanitized strings into `innerHTML`, which `web-ext lint` flags as `UNSAFE_VAR_ASSIGNMENT`. _Fixed 2025-11-15 by replacing all popup list/status rendering with DOM node creation._ 51 + - [x] **Firefox policy metadata** (`public/manifest.json`): Add `browser_specific_settings.gecko.data_collection_permissions` so AMO accepts new uploads. _Fixed 2025-11-15 by declaring `"required": ["none"]`._ 52 + - [x] **Options revert bug** (`src/options/options.ts:13-35`): Error handling always reverts to the _initial_ checkbox state, not the last successful save, because the captured `options` object never updates. _Fixed 2025-11-15 by tracking the last persisted values and reverting to those on failures._ 53 + - [ ] **Tooling determinism** (`package.json`): Almost every devDependency is pinned to `"latest"`, which makes CI/CD non-reproducible and has already caused surprise build breaks. 54 + - [x] **Cache write amplification** (`src/shared/cache.ts:138-205`): Every cache hit triggers a full `chrome.storage.local.set`, risking quota overruns (120 writes/min) and throttled service-worker lifetimes. _Fixed 2025-11-15 by debouncing read-hit persistence while keeping writes synchronous._ 55 + 56 + _When you clear an item, document the fix (date + PR/commit) here before removing it so future agents see the history._ 57 + 58 + ## Current Simplification State (2025-11-15) 59 + 60 + - Debug infrastructure removed; the new `debugLog/logError` helpers are the only logging hooks. 61 + - Popup relies on static CSS + browser theme variables; all DOM updates are sanitized via `createElement`. 62 + - Service worker precaches DID/handle pairs again by parsing every completed tab URL and only acting when `parseInput` recognizes a supported service. 63 + - `retry.ts`, legacy options helpers, and `wormholeDebug` hooks are gone; parser owns service-specific parsing logic. 64 + - Options include a third “Show cache debug info” toggle; popup shows cache hit/miss status when enabled. 65 + - Remaining backlog: pin dependencies for deterministic builds.
-361
CLAUDE.md
··· 1 - # CLAUDE.md 2 - 3 - This file provides **MANDATORY** guidance to Claude Code when working with code in this repository. 4 - 5 - ## 🚨 CRITICAL ANTI-CHEAT RULES 🚨 6 - 7 - **READ THIS FIRST - THESE ARE ABSOLUTE RULES:** 8 - 9 - 1. **ESLint/TypeScript errors MUST be fixed properly** - NEVER disabled 10 - 2. **Using `// eslint-disable`, `// @ts-ignore`, `// @ts-expect-error` = IMMEDIATE FAILURE** 11 - 3. **If you're tempted to disable a rule, STOP and fix the actual problem** 12 - 4. **No exceptions. No excuses. No shortcuts.** 13 - 14 - ## MANDATORY PRE-FLIGHT ACKNOWLEDGMENT 15 - 16 - Before ANY code work, you MUST type this acknowledgment: 17 - 18 - ``` 19 - I acknowledge that I will: 20 - - Fix all linting errors properly without disabling rules 21 - - Run all validation commands after every change 22 - - Not take shortcuts or cheat the system 23 - ``` 24 - 25 - ## MANDATORY WORKFLOW CHECKLIST 26 - 27 - **STOP AND READ**: You MUST complete this checklist for EVERY code change: 28 - 29 - 1. [ ] **START**: Print "Answered by: <model name>" before any work 30 - 2. [ ] **ACKNOWLEDGE**: Type the pre-flight acknowledgment above 31 - 3. [ ] **RE-READ**: Re-read this entire CLAUDE.md file 32 - 4. [ ] **PLAN**: Explain what you will change and why BEFORE making changes 33 - 5. [ ] **ASK BEFORE CODING**: Ask for explicit permission before you start coding 34 - 6. [ ] **CODE**: Make minimal, focused changes only 35 - 7. [ ] **VALIDATE**: Run ALL validation commands (see below) 36 - 8. [ ] **VERIFY**: Confirm all tests pass before proceeding 37 - 9. [ ] **DOCUMENT**: Update this file after completing each step with status 38 - 10. [ ] **COMMIT**: Make git commits as needed (never push) 39 - 40 - ## WHEN YOU ENCOUNTER LINTING/TYPE ERRORS 41 - 42 - **CORRECT APPROACH:** 43 - 44 - - Understand WHY the error exists 45 - - Fix the root cause of the error 46 - - Research the proper solution if needed 47 - - Ask for help if genuinely stuck 48 - 49 - **FORBIDDEN APPROACHES:** 50 - 51 - - ❌ Adding `// eslint-disable-*` comments 52 - - ❌ Adding `// @ts-ignore` or `// @ts-expect-error` 53 - - ❌ Modifying `.eslintrc` or `tsconfig.json` to relax rules 54 - - ❌ Removing the code that causes the error 55 - - ❌ Skipping the validation step 56 - 57 - ## VALIDATION COMMANDS (MANDATORY AFTER EVERY CODE CHANGE) 58 - 59 - **You MUST run ALL of these commands after ANY code change:** 60 - 61 - ```bash 62 - bun run format # Format code 63 - bun run lint # Check for linting errors (MUST PASS) 64 - bun run typecheck # Verify TypeScript types (MUST PASS) 65 - bun run test # Run all tests (add timeout wrapper if needed) 66 - bun run build:dev # Verify build works 67 - ``` 68 - 69 - **FAILURE PROTOCOL**: 70 - 71 - - If ANY command fails, you MUST fix the issue properly 72 - - DO NOT proceed until ALL commands pass 73 - - DO NOT disable rules to make them pass 74 - 75 - ## COMMON CHEAT SCENARIOS AND PROPER SOLUTIONS 76 - 77 - ### Scenario: "Unused variable" error 78 - 79 - ❌ WRONG: `// eslint-disable-next-line @typescript-eslint/no-unused-vars` 80 - ✅ RIGHT: Remove the unused variable or use it properly 81 - 82 - ### Scenario: "Type error" on third-party library 83 - 84 - ❌ WRONG: `// @ts-ignore` 85 - ✅ RIGHT: Add proper type definitions or use type assertions correctly 86 - 87 - ### Scenario: "Any type" warning 88 - 89 - ❌ WRONG: `// eslint-disable-next-line @typescript-eslint/no-explicit-any` 90 - ✅ RIGHT: Define proper types 91 - 92 - ### Scenario: Complex type issue 93 - 94 - ❌ WRONG: Give up and disable the check 95 - ✅ RIGHT: Ask for help with the specific type issue 96 - 97 - ## OTHER PROHIBITED ACTIONS 98 - 99 - - **NEVER** skip running the validation commands 100 - - **NEVER** remove functionality to "fix" test failures 101 - - **NEVER** add features beyond the problem scope 102 - - **NEVER** refactor working code without explicit request 103 - - **NEVER** include non-code content in code artifacts 104 - - **NEVER** provide sycophantic praise or cheerleading 105 - 106 - ## CODE GENERATION RULES 107 - 108 - ### Quality Standards 109 - 110 - - Write idiomatic, modern code with minimal dependencies 111 - - Prioritize long-term maintainability over speed 112 - - All code must pass ALL validation checks WITHOUT disabling any rules 113 - - Test coverage for all critical functionality 114 - 115 - ### When Writing Code 116 - 117 - - Use artifacts for all code generation 118 - - Show only relevant snippets when updating (not entire files) 119 - - Explain what changed and why in your response 120 - - Avoid comments for self-documenting code 121 - - Keep explanations outside code artifacts 122 - 123 - ### Project Structure 124 - 125 - - Request project structure before generating code (unless one-off script) 126 - - Stay within problem boundaries 127 - - Don't add unrequested features 128 - 129 - ## COMMUNICATION STYLE 130 - 131 - - Skip ALL flattery and praise 132 - - Challenge assumptions and present counter-evidence 133 - - Disagree openly when appropriate 134 - - Be direct and professional 135 - 136 - ## Build Commands Reference 137 - 138 - - `bun run build:dev` - Build development version of the extension 139 - - `bun run build:chrome` - Build Chrome extension 140 - - `bun run build:firefox` - Build Firefox extension 141 - - `bun run test:watch` - Run tests in watch mode 142 - 143 - For Bun API documentation, see: `node_modules/bun-types/docs/**.md` 144 - 145 - --- 146 - 147 - **FINAL REMINDER**: Using `eslint-disable` or `@ts-ignore` is NEVER acceptable. Fix the actual problem. 148 - 149 - # Problem Solving Approach 150 - 151 - For small tasks (linter errors, minor tweaks) proceed directly with implementation when the scope and approach are clear. Ask clarifying questions if needed. 152 - 153 - For Substantial Features and Changes: 154 - 155 - - Act as a Socratic dialogue partner and rubber duck 156 - - Assume the user may not have complete clarity on their vision 157 - - Act as a product manager / senior designer who cares about the final user experience 158 - - Guide the conversation toward concrete, actionable requirements 159 - - **Before writing any implementation plans or code:** 160 - - Ask clarifying questions about the intended user experience 161 - - Only ask one focused question at a time 162 - - Clarify edge cases and expected behaviors 163 - 164 - # Feature Implementation Plan Standards 165 - 166 - When creating implementation plans for a single feature: 167 - 168 - - Keep the plan scoped to only that feature. 169 - - Make sure you fully understand the feature before proceeding to write the plan. 170 - - Ask the user questions to clarify your understanding. The user often won't have figured out all the details of a feature before they begin to build it. You should act like an expert product manager and help them think through the user experience and technical implementation of each feature. 171 - - New plans should be markdown files stored inside the "planning" folder. Create this folder if it doesn't exist yet. 172 - 173 - **IMPORTANT**: These rules apply to code reviews and bugfixes as well. 174 - 175 - ## Document Structure 176 - 177 - - Maintain a task checklist with checkboxes (- [ ] or - [x]) 178 - - Include "Current Phase" and "Overall Progress" summaries 179 - - Write in present tense for current work, past tense for completed work 180 - - Include specific file names and locations when mentioning code changes 181 - - Make notes on completed tasks at the bottom with full details 182 - - Use consistent formatting for task IDs (P1.1, P1.2, etc.) 183 - 184 - ## Phase Completion Workflow 185 - 186 - After completing each phase of implementation: 187 - 188 - - Ask the user to test the implementation and verify it looks correct 189 - - Update the planning document to mark completed tasks as done 190 - - Move detailed completion notes to the bottom of the document 191 - - Update the "Current Phase" and "Overall Progress" sections 192 - - Update the "Last Updated" timestamp using `npm run date` which will give you today's date 193 - 194 - ## Task Documentation 195 - 196 - Each completed task should include: 197 - 198 - - Files modified/created 199 - - Key features implemented 200 - - Technical improvements made 201 - - User experience enhancements 202 - - Any issues resolved 203 - 204 - --- 205 - 206 - # at-wormhole-webextension 207 - 208 - ## Architecture 209 - 210 - This is a Manifest V3 browser extension that provides "wormhole" navigation between different AT Protocol/Bluesky services. The extension transforms URLs and identifiers from one service to equivalent URLs on other services. 211 - 212 - ### Core Components 213 - 214 - **Transform System**: 215 - 216 - - **Parser** (`src/shared/parser.ts`) - URL parsing with `parseInput()` that accepts URLs, handles, or DIDs 217 - - **Canonicalizer** (`src/shared/canonicalizer.ts`) - Pure transformation to standardized TransformInfo structure 218 - - **Resolver** (`src/shared/resolver.ts`) - Handle/DID resolution with AT Protocol API integration and retry logic 219 - - **Services** (`src/shared/services.ts`) - Service configuration and URL generation 220 - - **Cache** (`src/shared/cache.ts`) - BidirectionalMap and DidHandleCache for handle↔DID persistence 221 - - **Types** (`src/shared/types.ts`) - TypeScript interfaces and type definitions 222 - - **Constants** (`src/shared/constants.ts`) - Shared constants including NSID shortcuts 223 - - **Errors** (`src/shared/errors.ts`) - Discriminated union error types for neverthrow 224 - - **Retry** (`src/shared/retry.ts`) - Network retry logic with exponential backoff 225 - 226 - **Service Worker** (`src/background/service-worker.ts`): 227 - 228 - - Message handling for popup communication 229 - - Automatic tab URL monitoring with pre-caching 230 - - Proactive background resolution of handles/DIDs 231 - - Returns cache hit/miss metadata for debugging 232 - 233 - **Popup** (`src/popup/popup.ts`): 234 - 235 - - Main UI displaying destination links 236 - - Service worker communication for resolution 237 - - Cache management controls 238 - - Firefox theme integration via `theme.getCurrent()` 239 - - Development debug controls via `window.wormholeDebug` 240 - 241 - ### Build System 242 - 243 - - **Framework**: Vite with @crxjs/vite-plugin 244 - - **Development**: Injects background.scripts for Firefox MV3 compatibility 245 - - **Firefox Build**: Strips service_worker, adds scripts array, creates zip 246 - - **Chrome Build**: Standard MV3 manifest with zip output 247 - 248 - ### Supported Services 249 - 250 - The extension recognizes and transforms URLs from: 251 - 252 - - **bsky.app**, **deer.social** - Native Bluesky clients 253 - - **cred.blue** - Social credit score service 254 - - **tangled.sh** - AT Protocol-native git hosting 255 - - **blue.mackuba.eu/skythread** - Thread viewer 256 - - **atp.tools**, **pdsls.dev** - Developer tools 257 - - **clearsky.app** - Block checking service 258 - - **plc.directory**, **boat.kelinci.net** - DID:PLC information tools 259 - - **toolify.blue** - Various AT Protocol utilities 260 - - **repoview.edavis.dev** - Repository viewer for AT Protocol 261 - - **astrolabe.at** - AT Protocol navigation tool 262 - 263 - ### Special Features 264 - 265 - **Firefox Theme Integration**: 266 - 267 - - Automatically adopts Firefox's active theme colors 268 - - Falls back to `prefers-color-scheme` on Chrome 269 - - Maintains readability across all theme variations 270 - 271 - **Debug System** (`src/shared/debug.ts`): 272 - 273 - - Categorized logging: 🎨 Theme, 💾 Cache, 📝 Parsing, 🔧 Popup, ⚙️ Service Worker, 🔄 Transform 274 - - Runtime control via `window.wormholeDebug` in popup console 275 - - Persistent settings in `chrome.storage.local` 276 - 277 - **Cache System**: 278 - 279 - - Bidirectional handle↔DID mapping with automatic cleanup 280 - - Write-through persistence with LRU eviction 281 - - Proactive background resolution for visited URLs 282 - - Visual cache hit/miss indicators in development builds 283 - 284 - **Options System**: 285 - 286 - - **Show Emojis**: Toggle emoji display (default: true) 287 - - **Strict Mode**: Content-aware service filtering (default: false) 288 - - Cross-device sync via `chrome.storage.sync` 289 - 290 - ### Error Handling 291 - 292 - The extension uses **neverthrow** for comprehensive error handling: 293 - 294 - - All network operations return `ResultAsync<T, WormholeError>` 295 - - Discriminated union error types (NetworkError, ParseError, ValidationError, CacheError) 296 - - Explicit error handling enforced by ESLint 297 - - Automatic retry with exponential backoff for network failures 298 - 299 - ### Testing 300 - 301 - - **Framework**: Bun's built-in test runner 302 - - **Coverage**: URL parsing, handle resolution, URL generation, cache operations 303 - - **Mocking**: Simulated AT Protocol API responses 304 - - **Commands**: `bun run test` or `bun run test:watch` 305 - 306 - ## Implementation Status 307 - 308 - ### ✅ Completed 309 - 310 - 1. **Modular Architecture** - Transform system split into focused, single-responsibility modules 311 - 2. **neverthrow Integration** - Comprehensive error handling with Result types 312 - 3. **Retry Logic** - Network resilience with exponential backoff 313 - 4. **Cache System** - Reliable bidirectional persistence with LRU eviction 314 - 5. **Firefox Theme Support** - Dynamic theme adoption 315 - 6. **Options System** - User preferences with cross-device sync 316 - 7. **Debug System** - Categorized logging with runtime controls 317 - 318 - ### 📋 Remaining Tasks 319 - 320 - - **Type Safety** - Replace remaining `any`/`unknown` types 321 - - **Popup Error UI** - User-friendly error messages 322 - - **Test Coverage** - Additional edge case scenarios 323 - 324 - ## Adding New Services 325 - 326 - To add a new AT Protocol service, update `src/shared/services.ts`: 327 - 328 - ```typescript 329 - SERVICES.NEW_SERVICE = { 330 - emoji: '✨', 331 - name: 'example.com', 332 - contentSupport: 'full', // or 'profiles-and-posts', 'only-posts', 'only-profiles' 333 - 334 - // Optional: URL parsing configuration 335 - parsing: { 336 - hostname: 'example.com', 337 - patterns: { 338 - profileIdentifier: /^\/profile\/([^/]+)/, 339 - // Additional pattern options available 340 - }, 341 - }, 342 - 343 - // Required: URL generation 344 - buildUrl: (info) => { 345 - if (!info.handle) return null; 346 - if (info.rkey) { 347 - return `https://example.com/user/${info.handle}/post/${info.rkey}`; 348 - } 349 - return `https://example.com/user/${info.handle}`; 350 - }, 351 - 352 - // Optional: Input restrictions 353 - requiredFields: { 354 - handle: true, 355 - rkey: true, 356 - plcOnly: true, 357 - }, 358 - }; 359 - ``` 360 - 361 - No other code changes required!
+4 -1
public/manifest.json
··· 57 57 }, 58 58 "browser_specific_settings": { 59 59 "gecko": { 60 - "id": "wormhole@aliceisjustplaying" 60 + "id": "wormhole@aliceisjustplaying", 61 + "data_collection_permissions": { 62 + "required": ["none"] 63 + } 61 64 } 62 65 } 63 66 }
+94 -111
src/background/service-worker.ts
··· 1 1 import { parseInput } from '../shared/parser'; 2 2 import { resolveDidToHandle, resolveHandleToDid } from '../shared/resolver'; 3 3 import { DidHandleCache } from '../shared/cache'; 4 - import Debug from '../shared/debug'; 4 + import { debugLog, logError } from '../shared/logging'; 5 5 import type { SWMessage } from '../shared/types'; 6 6 7 7 const cache = new DidHandleCache(); ··· 11 11 12 12 async function initializeCache(): Promise<void> { 13 13 try { 14 - // Load debug config - optional, so we ignore errors 15 - await Debug.loadRuntimeConfig().unwrapOr(undefined); 16 - Debug.serviceWorker('Service worker starting, loading cache...'); 14 + debugLog('serviceWorker', 'Service worker starting, loading cache...'); 17 15 await cache.load().match( 18 16 () => { 19 - Debug.serviceWorker('Cache loaded successfully'); 17 + debugLog('serviceWorker', 'Cache loaded successfully'); 20 18 }, 21 19 (error) => { 22 - Debug.error('serviceWorker', 'Failed to load cache:', error); 20 + logError('serviceWorker', error); 23 21 // Continue with empty cache - don't throw 24 22 }, 25 23 ); ··· 28 26 try { 29 27 const oldCacheData = await chrome.storage.local.get('didHandleCache'); 30 28 if (oldCacheData.didHandleCache !== undefined) { 31 - Debug.serviceWorker('Found old cache format, cleaning up...'); 29 + debugLog('serviceWorker', 'Found old cache format, cleaning up...'); 32 30 await chrome.storage.local.remove('didHandleCache'); 33 - Debug.serviceWorker('Old cache cleaned up successfully'); 31 + debugLog('serviceWorker', 'Old cache cleaned up successfully'); 34 32 } 35 33 } catch (cleanupError: unknown) { 36 34 // Don't fail initialization if cleanup fails 37 - Debug.warn('serviceWorker', 'Failed to clean up old cache:', cleanupError); 35 + logError('serviceWorker', cleanupError); 38 36 } 39 37 } catch (error: unknown) { 40 - Debug.error('serviceWorker', 'Failed to initialize:', error); 38 + logError('serviceWorker', error); 41 39 // Continue with empty cache - don't throw 42 40 } 43 41 } ··· 58 56 sendResponse({ success: true }); 59 57 }, 60 58 (error) => { 61 - Debug.error('serviceWorker', 'Failed to update cache via message:', error); 59 + logError('serviceWorker', error); 62 60 sendResponse({ success: false, error: error.message }); 63 61 }, 64 62 ); 65 63 } catch (error: unknown) { 66 - Debug.error('serviceWorker', 'Cache initialization error:', error); 64 + logError('serviceWorker', error); 67 65 sendResponse({ success: false, error: 'Cache initialization failed' }); 68 66 } 69 67 })(); ··· 89 87 // Success - no action needed 90 88 }, 91 89 (cacheError) => { 92 - Debug.error('serviceWorker', 'Failed to cache DID->handle mapping:', cacheError); 90 + logError('serviceWorker', cacheError); 93 91 }, 94 92 ); 95 93 } 96 94 sendResponse({ handle, fromCache: false }); 97 95 }, 98 96 (error) => { 99 - Debug.error('serviceWorker', 'Resolve DID to handle failed:', error); 97 + logError('serviceWorker', error); 100 98 sendResponse({ handle: null, fromCache: false }); 101 99 }, 102 100 ); 103 101 } catch (error: unknown) { 104 - Debug.error('serviceWorker', 'GET_HANDLE error:', error); 102 + logError('serviceWorker', error); 105 103 sendResponse({ handle: null, fromCache: false, error: 'Cache initialization failed' }); 106 104 } 107 105 })(); ··· 127 125 // Success - no action needed 128 126 }, 129 127 (cacheError) => { 130 - Debug.error('serviceWorker', 'Failed to cache handle->DID mapping:', cacheError); 128 + logError('serviceWorker', cacheError); 131 129 }, 132 130 ); 133 131 } 134 132 sendResponse({ did, fromCache: false }); 135 133 }, 136 134 (error) => { 137 - Debug.error('serviceWorker', 'Resolve handle to DID failed:', error); 135 + logError('serviceWorker', error); 138 136 sendResponse({ did: null, fromCache: false }); 139 137 }, 140 138 ); 141 139 } catch (error: unknown) { 142 - Debug.error('serviceWorker', 'GET_DID error:', error); 140 + logError('serviceWorker', error); 143 141 sendResponse({ did: null, fromCache: false, error: 'Cache initialization failed' }); 144 142 } 145 143 })(); 146 144 return true; 147 145 } 148 146 149 - // DEBUG_LOG 150 - if (request.type === 'DEBUG_LOG' && typeof request.message === 'string') { 151 - Debug.popup('Popup message:', request.message); 152 - sendResponse({ success: true }); 153 - return true; 154 - } 155 - 156 147 // CLEAR_CACHE 157 148 if (request.type === 'CLEAR_CACHE') { 158 149 void (async () => { ··· 163 154 sendResponse({ success: true }); 164 155 }, 165 156 (error) => { 166 - Debug.error('serviceWorker', 'Failed to clear cache:', error); 157 + logError('serviceWorker', error); 167 158 sendResponse({ success: false, error: error.message }); 168 159 }, 169 160 ); 170 161 } catch (error: unknown) { 171 - Debug.error('serviceWorker', 'Clear cache initialization error:', error); 162 + logError('serviceWorker', error); 172 163 sendResponse({ success: false, error: 'Cache initialization failed' }); 173 164 } 174 165 })(); ··· 180 171 181 172 chrome.runtime.onMessage.addListener(messageListener); 182 173 183 - const tabUpdateListener = (_tabId: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => { 184 - void (async () => { 185 - try { 186 - await cacheInitialized; 187 - if (info.status !== 'complete' || !tab.url) return; 174 + const tabUpdateListener = (_tabId: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab): void => { 175 + if (info.status !== 'complete' || !tab.url) { 176 + return; 177 + } 178 + 179 + if (!tab.url.startsWith('http')) { 180 + return; 181 + } 188 182 189 - const parseResult = parseInput(tab.url); 190 - await parseResult.match( 191 - async (data) => { 192 - if (!data || (!data.did && !data.handle)) return; 183 + void precacheFromUrl(tab.url); 184 + }; 193 185 194 - // Case 1: URL had both DID and handle, cache the pair 195 - if (data.did && data.handle) { 196 - await cache.set(data.did, data.handle).match( 197 - () => { 198 - // Success - no action needed 199 - }, 200 - (error) => { 201 - Debug.error('serviceWorker', 'Failed to cache DID+handle pair from URL:', error); 202 - }, 203 - ); 204 - return; 205 - } 186 + chrome.tabs.onUpdated.addListener(tabUpdateListener); 187 + 188 + async function precacheFromUrl(rawUrl: string): Promise<void> { 189 + try { 190 + await cacheInitialized; 191 + const parseResult = parseInput(rawUrl); 192 + await parseResult.match( 193 + async (info) => { 194 + if (!info) { 195 + return; 196 + } 206 197 207 - // Case 2: URL had only DID, resolve handle if not cached 208 - if (data.did && !data.handle) { 209 - const cachedHandle = cache.getHandle(data.did); 210 - if (cachedHandle) { 211 - return; 212 - } 213 - const result = await resolveDidToHandle(data.did); 214 - await result.match( 215 - async (handle) => { 216 - if (handle && data.did) { 217 - await cache.set(data.did, handle).match( 218 - () => { 219 - // Success - no action needed 220 - }, 221 - (cacheError) => { 222 - Debug.error('serviceWorker', 'Failed to cache background DID->handle resolution:', cacheError); 223 - }, 224 - ); 225 - } 226 - }, 227 - (error) => { 228 - Debug.error('serviceWorker', 'Background DID->handle resolution failed:', error); 229 - }, 230 - ); 231 - return; 232 - } 198 + if (info.did && info.handle) { 199 + await cache.set(info.did, info.handle).match( 200 + () => undefined, 201 + (error) => { 202 + logError('serviceWorker', error); 203 + }, 204 + ); 205 + return; 206 + } 233 207 234 - // Case 3: URL had only handle, resolve DID if not cached 235 - if (data.handle && !data.did) { 236 - const cachedDid = cache.getDid(data.handle); 237 - if (cachedDid) { 238 - return; 239 - } 240 - const result = await resolveHandleToDid(data.handle); 241 - await result.match( 242 - async (did) => { 243 - if (did && data.handle) { 244 - await cache.set(did, data.handle).match( 245 - () => { 246 - // Success - no action needed 247 - }, 248 - (cacheError) => { 249 - Debug.error('serviceWorker', 'Failed to cache background handle->DID resolution:', cacheError); 250 - }, 251 - ); 252 - } 253 - }, 254 - (error) => { 255 - Debug.error('serviceWorker', 'Background handle->DID resolution failed:', error); 256 - }, 257 - ); 258 - return; 259 - } 260 - }, 261 - (error) => { 262 - Debug.error('serviceWorker', 'URL parsing failed in background:', error); 263 - }, 264 - ); 265 - } catch (error: unknown) { 266 - Debug.error('serviceWorker', 'Tab update error:', error); 267 - } 268 - })(); 269 - }; 208 + if (info.did && !info.handle) { 209 + const cachedHandle = cache.getHandle(info.did); 210 + if (cachedHandle) return; 211 + const result = await resolveDidToHandle(info.did); 212 + await result.match( 213 + async (handle) => { 214 + if (handle) { 215 + await cache.set(info.did!, handle).match( 216 + () => undefined, 217 + (error) => logError('serviceWorker', error), 218 + ); 219 + } 220 + }, 221 + (error) => { 222 + logError('serviceWorker', error); 223 + }, 224 + ); 225 + return; 226 + } 270 227 271 - chrome.tabs.onUpdated.addListener(tabUpdateListener); 228 + if (info.handle && !info.did) { 229 + const cachedDid = cache.getDid(info.handle); 230 + if (cachedDid) return; 231 + const result = await resolveHandleToDid(info.handle); 232 + await result.match( 233 + async (did) => { 234 + if (did) { 235 + await cache.set(did, info.handle!).match( 236 + () => undefined, 237 + (error) => logError('serviceWorker', error), 238 + ); 239 + } 240 + }, 241 + (error) => { 242 + logError('serviceWorker', error); 243 + }, 244 + ); 245 + } 246 + }, 247 + (error) => { 248 + logError('serviceWorker', error); 249 + }, 250 + ); 251 + } catch (error) { 252 + logError('serviceWorker', error); 253 + } 254 + }
+7
src/options/options.html
··· 23 23 <span class="checkbox-text">Strict mode</span> 24 24 </label> 25 25 </div> 26 + 27 + <div class="option-group"> 28 + <label class="checkbox-label"> 29 + <input type="checkbox" id="showCacheDebug" /> 30 + <span class="checkbox-text">Show cache debug info</span> 31 + </label> 32 + </div> 26 33 </div> 27 34 28 35 <script type="module" src="options.ts"></script>
+27 -15
src/options/options.ts
··· 4 4 async function initializeOptions(): Promise<void> { 5 5 const showEmojisCheckbox = document.getElementById('showEmojis') as HTMLInputElement | null; 6 6 const strictModeCheckbox = document.getElementById('strictMode') as HTMLInputElement | null; 7 + const showCacheDebugCheckbox = document.getElementById('showCacheDebug') as HTMLInputElement | null; 7 8 8 - if (!showEmojisCheckbox || !strictModeCheckbox) { 9 + if (!showEmojisCheckbox || !strictModeCheckbox || !showCacheDebugCheckbox) { 9 10 console.error('Required checkboxes not found'); 10 11 return; 11 12 } 12 13 13 14 // Load current options 14 15 const optionsResult = await getOptions(); 15 - const options = optionsResult.unwrapOr(getDefaultOptions()); 16 + let currentOptions = optionsResult.unwrapOr(getDefaultOptions()); 16 17 17 - showEmojisCheckbox.checked = options.showEmojis; 18 - strictModeCheckbox.checked = options.strictMode; 18 + showEmojisCheckbox.checked = currentOptions.showEmojis; 19 + strictModeCheckbox.checked = currentOptions.strictMode; 20 + showCacheDebugCheckbox.checked = currentOptions.showCacheDebug; 19 21 20 22 // Update options when checkboxes change 21 23 const updateOptions = () => { 22 24 const newOptions: WormholeOptions = { 23 25 showEmojis: showEmojisCheckbox.checked, 24 26 strictMode: strictModeCheckbox.checked, 27 + showCacheDebug: showCacheDebugCheckbox.checked, 25 28 }; 26 29 27 - void setOptions(newOptions).then((result) => { 28 - return result.match( 29 - () => undefined, // Success - no notification needed 30 - (error) => { 31 - console.error('Failed to save options:', error); 32 - // Revert checkboxes to previous state 33 - showEmojisCheckbox.checked = options.showEmojis; 34 - strictModeCheckbox.checked = options.strictMode; 35 - }, 36 - ); 37 - }); 30 + const previousOptions = { ...currentOptions }; 31 + 32 + void setOptions(newOptions).match( 33 + () => { 34 + currentOptions = { ...newOptions }; 35 + }, 36 + (error) => { 37 + console.error('Failed to save options:', error); 38 + // Revert checkboxes to the last known good state 39 + showEmojisCheckbox.checked = previousOptions.showEmojis; 40 + strictModeCheckbox.checked = previousOptions.strictMode; 41 + showCacheDebugCheckbox.checked = previousOptions.showCacheDebug; 42 + currentOptions = previousOptions; 43 + }, 44 + ); 38 45 }; 39 46 40 47 showEmojisCheckbox.addEventListener('change', updateOptions); 41 48 strictModeCheckbox.addEventListener('change', updateOptions); 49 + showCacheDebugCheckbox.addEventListener('change', updateOptions); 42 50 43 51 // Listen for external changes 44 52 const handleExternalChanges = (changes: Partial<WormholeOptions>) => { ··· 48 56 if (changes.strictMode !== undefined) { 49 57 strictModeCheckbox.checked = changes.strictMode; 50 58 } 59 + if (changes.showCacheDebug !== undefined) { 60 + showCacheDebugCheckbox.checked = changes.showCacheDebug; 61 + } 62 + currentOptions = { ...currentOptions, ...changes }; 51 63 }; 52 64 53 65 onOptionsChange(handleExternalChanges);
+35
src/popup/popup.css
··· 103 103 background-color: #555; 104 104 } 105 105 } 106 + 107 + body.firefox-theme { 108 + background: var(--theme-bg, var(--fallback-bg)); 109 + color: var(--theme-text, var(--fallback-text)); 110 + } 111 + 112 + body.firefox-theme button, 113 + body.firefox-theme ul#dest a { 114 + background: var(--theme-button-bg, var(--fallback-button-bg)); 115 + color: var(--theme-button-text, var(--fallback-button-text)); 116 + border-color: var(--theme-border, var(--fallback-border)); 117 + } 118 + 119 + body.firefox-theme button:hover, 120 + body.firefox-theme ul#dest a:hover { 121 + background: var(--theme-button-hover, var(--theme-button-bg, var(--fallback-hover-bg))); 122 + } 123 + 124 + body.firefox-theme hr { 125 + background-color: var(--theme-border, var(--fallback-border)); 126 + } 127 + 128 + .debug-info { 129 + font-size: 11px; 130 + text-align: center; 131 + margin-top: 8px; 132 + color: #666; 133 + min-height: 12px; 134 + } 135 + 136 + @media (prefers-color-scheme: dark) { 137 + .debug-info { 138 + color: #aaa; 139 + } 140 + }
+1 -4
src/popup/popup.html
··· 11 11 <div style="text-align: center"> 12 12 <button id="emptyCacheBtn" style="padding: 4px 8px; font-size: 12px">Empty Handle+DID Cache</button> 13 13 </div> 14 - <div 15 - id="debugInfo" 16 - style="font-size: 10px; text-align: center; margin-top: 8px; color: #666; min-height: 12px" 17 - ></div> 14 + <div id="debugInfo" class="debug-info" hidden></div> 18 15 <script type="module" src="./popup.ts"></script> 19 16 </body> 20 17 </html>
+94 -157
src/popup/popup.ts
··· 1 1 import { parseInput } from '../shared/parser'; 2 2 import { buildDestinations } from '../shared/services'; 3 - import { loadOptions } from '../shared/options'; 4 - import Debug from '../shared/debug'; 5 - import type { BrowserWithTheme, DebugConfig, Destination, WindowWithDebug } from '../shared/types'; 3 + import { getOptions, getDefaultOptions } from '../shared/options'; 4 + import type { BrowserWithTheme, Destination } from '../shared/types'; 6 5 import { ResultAsync } from 'neverthrow'; 7 6 import { runtimeError, type RuntimeError } from '../shared/errors'; 8 - 9 - /** 10 - * Applies Firefox theme colors to the popup if available, falls back to CSS media query 11 - */ 12 - async function applyTheme(): Promise<void> { 13 - Debug.theme('Theme detection starting...'); 7 + import { debugLog } from '../shared/logging'; 14 8 15 - // Only attempt theme detection in Firefox 9 + async function applyFirefoxTheme(): Promise<void> { 16 10 const browserWithTheme = chrome as BrowserWithTheme; 17 11 if (!browserWithTheme.theme?.getCurrent) { 18 - Debug.theme('Theme API not available (likely Chrome or older Firefox)'); 19 12 return; 20 13 } 21 14 22 - Debug.theme('Theme API available, getting current theme...'); 23 - 24 15 try { 25 16 const theme = await browserWithTheme.theme.getCurrent(); 26 - Debug.theme('Raw theme object:', theme); 27 - 28 - if (!theme.colors) { 29 - Debug.warn('theme', 'No colors in theme, using CSS fallback'); 30 - return; 31 - } 32 - 33 17 const colors = theme.colors; 34 - Debug.theme('Theme colors found:', colors); 35 - 36 - const style = document.createElement('style'); 37 - style.id = 'firefox-theme-override'; 38 - 39 - // Build CSS custom properties from theme colors 40 - const cssVars = []; 41 - 42 - // Use popup colors first (most appropriate for our popup) 43 - if (colors.popup) { 44 - cssVars.push(`--theme-bg: ${colors.popup}`); 45 - Debug.theme(`Background: ${colors.popup}`); 46 - } else if (colors.toolbar) { 47 - cssVars.push(`--theme-bg: ${colors.toolbar}`); 48 - Debug.theme(`Background (toolbar fallback): ${colors.toolbar}`); 49 - } 50 - 51 - if (colors.popup_text) { 52 - cssVars.push(`--theme-text: ${colors.popup_text}`); 53 - Debug.theme(`Text: ${colors.popup_text}`); 54 - } else if (colors.toolbar_text) { 55 - cssVars.push(`--theme-text: ${colors.toolbar_text}`); 56 - Debug.theme(`Text (toolbar fallback): ${colors.toolbar_text}`); 57 - } 58 - 59 - if (colors.popup_border) { 60 - cssVars.push(`--theme-border: ${colors.popup_border}`); 61 - Debug.theme(`Border: ${colors.popup_border}`); 62 - } else if (colors.toolbar_field_border) { 63 - cssVars.push(`--theme-border: ${colors.toolbar_field_border}`); 64 - Debug.theme(`Border (field fallback): ${colors.toolbar_field_border}`); 65 - } 66 - 67 - // For buttons, use popup_highlight or toolbar_field colors 68 - if (colors.popup_highlight) { 69 - cssVars.push(`--theme-button-bg: ${colors.popup_highlight}`); 70 - Debug.theme(`Button bg: ${colors.popup_highlight}`); 71 - } else if (colors.toolbar_field) { 72 - cssVars.push(`--theme-button-bg: ${colors.toolbar_field}`); 73 - Debug.theme(`Button bg (field fallback): ${colors.toolbar_field}`); 74 - } 18 + if (!colors) return; 75 19 76 - if (colors.popup_highlight_text) { 77 - cssVars.push(`--theme-button-text: ${colors.popup_highlight_text}`); 78 - Debug.theme(`Button text: ${colors.popup_highlight_text}`); 79 - } else if (colors.toolbar_field_text) { 80 - cssVars.push(`--theme-button-text: ${colors.toolbar_field_text}`); 81 - Debug.theme(`Button text (field fallback): ${colors.toolbar_field_text}`); 82 - } 20 + const cssVars: string[] = []; 21 + const pick = (...values: (string | undefined)[]) => values.find((value) => typeof value === 'string'); 83 22 84 - // Hover effects 85 - if (colors.button_background_hover) { 86 - cssVars.push(`--theme-button-hover: ${colors.button_background_hover}`); 87 - Debug.theme(`Button hover: ${colors.button_background_hover}`); 88 - } 23 + const bg = pick(colors.popup, colors.toolbar); 24 + const text = pick(colors.popup_text, colors.toolbar_text); 25 + const border = pick(colors.popup_border, colors.toolbar_field_border); 26 + const buttonBg = pick(colors.popup_highlight, colors.toolbar_field); 27 + const buttonText = pick(colors.popup_highlight_text, colors.toolbar_field_text); 28 + const buttonHover = colors.button_background_hover; 89 29 90 - Debug.theme(`Total CSS vars created: ${cssVars.length}`); 30 + if (bg) cssVars.push(`--theme-bg: ${bg}`); 31 + if (text) cssVars.push(`--theme-text: ${text}`); 32 + if (border) cssVars.push(`--theme-border: ${border}`); 33 + if (buttonBg) cssVars.push(`--theme-button-bg: ${buttonBg}`); 34 + if (buttonText) cssVars.push(`--theme-button-text: ${buttonText}`); 35 + if (buttonHover) cssVars.push(`--theme-button-hover: ${buttonHover}`); 91 36 92 - // Only apply if we have meaningful theme colors 93 - if (cssVars.length > 0) { 94 - style.textContent = ` 95 - :root { ${cssVars.join('; ')}; } 96 - body.firefox-theme { 97 - background: var(--theme-bg, var(--fallback-bg)) !important; 98 - color: var(--theme-text, var(--fallback-text)) !important; 99 - } 100 - body.firefox-theme button, 101 - body.firefox-theme ul#dest a { 102 - background: var(--theme-button-bg, var(--fallback-button-bg)) !important; 103 - color: var(--theme-button-text, var(--fallback-button-text)) !important; 104 - border-color: var(--theme-border, var(--fallback-border)) !important; 105 - } 106 - body.firefox-theme button:hover, 107 - body.firefox-theme ul#dest a:hover { 108 - background: var(--theme-button-hover, var(--theme-button-bg, var(--fallback-hover-bg))) !important; 109 - filter: brightness(1.1); 110 - } 111 - body.firefox-theme hr { 112 - background-color: var(--theme-border, var(--fallback-border)) !important; 113 - } 114 - `; 37 + if (!cssVars.length) return; 115 38 39 + let style = document.getElementById('firefox-theme-vars') as HTMLStyleElement | null; 40 + if (!style) { 41 + style = document.createElement('style'); 42 + style.id = 'firefox-theme-vars'; 116 43 document.head.appendChild(style); 117 - document.body.classList.add('firefox-theme'); 118 - Debug.theme('Firefox theme applied successfully!'); 119 - Debug.theme('CSS applied:', style.textContent); 120 - } else { 121 - Debug.warn('theme', 'No usable theme colors found, using CSS fallback'); 122 44 } 45 + style.textContent = `:root { ${cssVars.join('; ')}; }`; 46 + document.body.classList.add('firefox-theme'); 123 47 } catch (error) { 124 - Debug.error('theme', 'Theme detection failed:', error); 48 + debugLog('popup', 'Firefox theme detection failed', error); 125 49 } 126 50 } 127 51 ··· 150 74 */ 151 75 const domContentLoadedHandler = () => { 152 76 void (async () => { 153 - // Load debug configuration and options 154 - // We don't need to handle errors here - debug config is optional 155 - await Debug.loadRuntimeConfig().unwrapOr(undefined); 156 - const options = await loadOptions(); 157 - Debug.popup('Popup initialized'); 77 + await applyFirefoxTheme(); 78 + const optionsResult = await getOptions(); 79 + const options = optionsResult.unwrapOr(getDefaultOptions()); 80 + debugLog('popup', 'Popup initialized'); 158 81 159 - // Apply Firefox theme if available 160 - await applyTheme(); 161 82 const list = document.getElementById('dest') as HTMLUListElement; 162 83 const emptyBtn = document.getElementById('emptyCacheBtn') as HTMLButtonElement; 163 - const debugInfo = document.getElementById('debugInfo') as HTMLDivElement; 164 84 165 85 // Close popup when a destination link is clicked (Firefox MV3 does not auto-close) 166 86 list.addEventListener('click', (e: MouseEvent) => { ··· 173 93 } 174 94 }); 175 95 96 + const createStatusItem = (msg: string): HTMLLIElement => { 97 + const item = document.createElement('li'); 98 + item.textContent = msg; 99 + return item; 100 + }; 101 + 102 + const createDestinationItem = ({ url, label }: Destination): HTMLLIElement => { 103 + const item = document.createElement('li'); 104 + const anchor = document.createElement('a'); 105 + anchor.href = url; 106 + anchor.target = '_blank'; 107 + anchor.rel = 'noopener noreferrer'; 108 + anchor.textContent = label; 109 + item.appendChild(anchor); 110 + return item; 111 + }; 112 + 113 + const debugInfo = document.getElementById('debugInfo') as HTMLDivElement | null; 114 + 115 + const setDebugInfo = (msg: string): void => { 116 + if (!debugInfo) return; 117 + if (!options.showCacheDebug) { 118 + debugInfo.hidden = true; 119 + debugInfo.textContent = ''; 120 + return; 121 + } 122 + debugInfo.hidden = false; 123 + debugInfo.textContent = msg; 124 + }; 125 + 126 + if (debugInfo) { 127 + if (options.showCacheDebug) { 128 + debugInfo.hidden = false; 129 + debugInfo.textContent = 'Cache debug enabled'; 130 + } else { 131 + debugInfo.hidden = true; 132 + debugInfo.textContent = ''; 133 + } 134 + } 135 + 176 136 const showStatus = (msg: string): void => { 177 - Debug.popup('Showing status:', msg); 178 - list.innerHTML = `<li>${msg}</li>`; 137 + debugLog('popup', 'Showing status:', msg); 138 + list.replaceChildren(createStatusItem(msg)); 179 139 }; 180 - const createItem = ({ url, label }: Destination): string => ` 181 - <li> 182 - <a href="${url}" target="_blank" rel="noopener noreferrer"> 183 - ${label} 184 - </a> 185 - </li>`; 140 + 186 141 const render = (ds: Destination[]): void => { 187 - Debug.popup('Rendering destinations:', ds.length); 142 + debugLog('popup', 'Rendering destinations:', ds.length); 188 143 if (ds.length) { 189 - list.innerHTML = ds.map(createItem).join(''); 144 + const fragment = document.createDocumentFragment(); 145 + ds.forEach((destination) => { 146 + fragment.appendChild(createDestinationItem(destination)); 147 + }); 148 + list.replaceChildren(fragment); 190 149 } else { 191 150 showStatus('No actions available'); 192 151 } ··· 197 156 const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 198 157 const activeUrl = tabs[0]?.url ?? ''; 199 158 const raw: string = payload ?? activeUrl; 200 - Debug.parsing('Processing input:', raw); 159 + debugLog('parsing', 'Processing input:', raw); 201 160 if (!raw) { 202 161 showStatus('No URL or payload provided'); 203 162 return; ··· 206 165 const parseResult = parseInput(raw); 207 166 void parseResult.match( 208 167 async (info) => { 209 - Debug.parsing('Parse result:', info); 168 + debugLog('parsing', 'Parse result:', info); 210 169 if (!info || (!info.did && !info.handle && !info.atUri)) { 211 170 showStatus('No DID or at:// URI found in current tab.'); 212 171 return; ··· 229 188 (response) => { 230 189 const handle = response.handle; 231 190 if (handle && import.meta.env.MODE === 'development') { 232 - debugInfo.textContent = 233 - response.fromCache ? 'handle was fetched from cache' : 'was forced to resolve handle'; 191 + debugLog('popup', response.fromCache ? 'handle cache hit' : 'handle resolved'); 192 + } 193 + if (handle) { 194 + setDebugInfo(response.fromCache ? 'DID cache hit' : 'DID cache miss'); 195 + } else { 196 + setDebugInfo('DID cache unresolved'); 234 197 } 235 198 return { handleToUse: handle, errorStatusWasSet: false }; 236 199 }, ··· 266 229 (response) => { 267 230 const did = response.did; 268 231 if (did && import.meta.env.MODE === 'development') { 269 - debugInfo.textContent = response.fromCache ? 'did was fetched from cache' : 'was forced to resolve did'; 232 + debugLog('popup', response.fromCache ? 'did cache hit' : 'did resolved'); 233 + } 234 + if (did) { 235 + setDebugInfo(response.fromCache ? 'Handle cache hit' : 'Handle cache miss'); 236 + } else { 237 + setDebugInfo('Handle cache unresolved'); 270 238 } 271 239 return { didToUse: did, errorStatusWasSet: false }; 272 240 }, ··· 334 302 }; 335 303 336 304 document.addEventListener('DOMContentLoaded', domContentLoadedHandler); 337 - 338 - // Expose debug controls to browser console for development 339 - // Usage: window.wormholeDebug.theme(true) or window.wormholeDebug.getConfig() 340 - 341 - (window as unknown as WindowWithDebug).wormholeDebug = { 342 - theme: (enabled: boolean) => { 343 - Debug.setCategory('theme', enabled); 344 - }, 345 - cache: (enabled: boolean) => { 346 - Debug.setCategory('cache', enabled); 347 - }, 348 - parsing: (enabled: boolean) => { 349 - Debug.setCategory('parsing', enabled); 350 - }, 351 - popup: (enabled: boolean) => { 352 - Debug.setCategory('popup', enabled); 353 - }, 354 - serviceWorker: (enabled: boolean) => { 355 - Debug.setCategory('serviceWorker', enabled); 356 - }, 357 - transform: (enabled: boolean) => { 358 - Debug.setCategory('transform', enabled); 359 - }, 360 - getConfig: () => Debug.getConfig(), 361 - all: (enabled: boolean) => { 362 - const categories: (keyof DebugConfig)[] = ['theme', 'cache', 'parsing', 'popup', 'serviceWorker', 'transform']; 363 - categories.forEach((category) => { 364 - Debug.setCategory(category, enabled); 365 - }); 366 - }, 367 - };
+58 -12
src/shared/cache.ts
··· 1 1 import { ResultAsync, ok, err } from 'neverthrow'; 2 2 import type { WormholeError } from './errors'; 3 3 import { cacheError } from './errors'; 4 - import { logError } from './debug'; 4 + import { logError } from './logging'; 5 5 6 6 export class BidirectionalMap<K1, K2> { 7 7 private forwardMap = new Map<K1, K2>(); ··· 59 59 lastAccessed: number; 60 60 } 61 61 62 + interface DidHandleCacheOptions { 63 + maxStorageSize?: number; 64 + persistDebounceMs?: number; 65 + } 66 + 62 67 export class DidHandleCache { 63 68 private cache = new BidirectionalMap<string, string>(); 64 69 private lastAccessTime = new Map<string, number>(); 65 70 private maxStorageSize: number; 66 71 private static readonly STORAGE_KEY = 'wormhole-cache'; 67 72 private static readonly DEFAULT_MAX_SIZE = 4 * 1024 * 1024; // 4MB 73 + private static readonly DEFAULT_PERSIST_DEBOUNCE_MS = 1500; 74 + private persistTimer: ReturnType<typeof setTimeout> | null = null; 75 + private pendingPersist = false; 76 + private readonly persistDebounceMs: number; 68 77 69 - constructor(maxStorageSize: number = DidHandleCache.DEFAULT_MAX_SIZE) { 70 - this.maxStorageSize = maxStorageSize; 78 + constructor(options: DidHandleCacheOptions = {}) { 79 + this.maxStorageSize = options.maxStorageSize ?? DidHandleCache.DEFAULT_MAX_SIZE; 80 + this.persistDebounceMs = options.persistDebounceMs ?? DidHandleCache.DEFAULT_PERSIST_DEBOUNCE_MS; 71 81 } 72 82 73 83 load(): ResultAsync<void, WormholeError> { ··· 154 164 clear(): ResultAsync<void, WormholeError> { 155 165 this.cache.clear(); 156 166 this.lastAccessTime.clear(); 167 + this.cancelScheduledPersist(); 157 168 return ResultAsync.fromPromise(chrome.storage.local.remove(DidHandleCache.STORAGE_KEY), (e) => 158 169 cacheError('Failed to clear cache from storage', 'clear', e), 159 170 ).map(() => undefined); ··· 195 206 private updateLastAccessed(did: string): void { 196 207 this.lastAccessTime.set(did, Date.now()); 197 208 198 - // Fire-and-forget persistence with error logging 199 - void this.persist().match( 200 - () => { 201 - // Success - no action needed 202 - }, 203 - (error) => { 204 - logError('CACHE', error, { operation: 'updateLastAccessed', did }); 205 - }, 206 - ); 209 + this.schedulePersist(did); 207 210 } 208 211 209 212 private checkSizeAndEvict(): void { ··· 263 266 typeof (entry as CacheEntry).handle === 'string' && 264 267 typeof (entry as CacheEntry).lastAccessed === 'number' 265 268 ); 269 + } 270 + 271 + private schedulePersist(did: string): void { 272 + if (this.persistDebounceMs <= 0) { 273 + void this.persist().match( 274 + () => { 275 + // Success - no action needed 276 + }, 277 + (error) => { 278 + logError('CACHE', error, { operation: 'immediatePersist', did }); 279 + }, 280 + ); 281 + return; 282 + } 283 + 284 + this.pendingPersist = true; 285 + if (this.persistTimer) { 286 + return; 287 + } 288 + 289 + this.persistTimer = setTimeout(() => { 290 + this.persistTimer = null; 291 + if (!this.pendingPersist) { 292 + return; 293 + } 294 + this.pendingPersist = false; 295 + void this.persist().match( 296 + () => { 297 + // Success - no action needed 298 + }, 299 + (error) => { 300 + logError('CACHE', error, { operation: 'debouncedPersist', did }); 301 + }, 302 + ); 303 + }, this.persistDebounceMs); 304 + } 305 + 306 + private cancelScheduledPersist(): void { 307 + if (this.persistTimer) { 308 + clearTimeout(this.persistTimer); 309 + this.persistTimer = null; 310 + } 311 + this.pendingPersist = false; 266 312 } 267 313 }
+1 -1
src/shared/canonicalizer.ts
··· 3 3 import type { TransformInfo } from './types'; 4 4 import type { WormholeError } from './errors'; 5 5 import { validationError } from './errors'; 6 - import { logError } from './debug'; 6 + import { logError } from './logging'; 7 7 8 8 /** 9 9 * Canonicalizes an input fragment into a standard info object.
-149
src/shared/debug.ts
··· 1 - /** 2 - * Centralized debug logging utility for the extension 3 - * Controls debug output by category with build-time and runtime flags 4 - */ 5 - 6 - import type { DebugConfig } from './types'; 7 - import { isWormholeError, storageError, type StorageError } from './errors'; 8 - import { ResultAsync } from 'neverthrow'; 9 - 10 - // eslint-disable-next-line @typescript-eslint/no-extraneous-class 11 - export default class Debug { 12 - private static getDefaultConfig(): DebugConfig { 13 - return { 14 - theme: import.meta.env.MODE === 'development' || import.meta.env.VITE_DEBUG_THEME === 'true', 15 - cache: import.meta.env.MODE === 'development' || import.meta.env.VITE_DEBUG_CACHE === 'true', 16 - parsing: import.meta.env.MODE === 'development' || import.meta.env.VITE_DEBUG_PARSING === 'true', 17 - popup: import.meta.env.MODE === 'development' || import.meta.env.VITE_DEBUG_POPUP === 'true', 18 - serviceWorker: import.meta.env.MODE === 'development' || import.meta.env.VITE_DEBUG_SW === 'true', 19 - transform: import.meta.env.MODE === 'development' || import.meta.env.VITE_DEBUG_TRANSFORM === 'true', 20 - }; 21 - } 22 - 23 - private static config: DebugConfig = this.getDefaultConfig(); 24 - 25 - /** 26 - * Load runtime debug overrides from chrome.storage 27 - * Call this in popup and service worker initialization 28 - */ 29 - static loadRuntimeConfig(): ResultAsync<void, StorageError> { 30 - return ResultAsync.fromPromise(chrome.storage.local.get('debugConfig'), (error) => 31 - storageError('Failed to load debug config', 'get', error), 32 - ).map((result) => { 33 - if (result.debugConfig && typeof result.debugConfig === 'object') { 34 - // Use stored config completely, with defaults as fallback 35 - this.config = { ...this.getDefaultConfig(), ...(result.debugConfig as Partial<DebugConfig>) }; 36 - } 37 - return undefined; 38 - }); 39 - } 40 - 41 - /** 42 - * Save current debug config to storage for runtime persistence 43 - */ 44 - static saveRuntimeConfig(): ResultAsync<void, StorageError> { 45 - return ResultAsync.fromPromise(chrome.storage.local.set({ debugConfig: this.config }), (error) => 46 - storageError('Failed to save debug config', 'set', error), 47 - ).map(() => undefined); 48 - } 49 - 50 - /** 51 - * Enable/disable debug category at runtime 52 - */ 53 - static setCategory(category: keyof DebugConfig, enabled: boolean): void { 54 - this.config[category] = enabled; 55 - // Fire and forget - we don't need to wait for the save 56 - void this.saveRuntimeConfig().match( 57 - () => undefined, // Success - no action needed 58 - (error) => console.error('Failed to save debug config:', error), 59 - ); 60 - } 61 - 62 - /** 63 - * Get current debug configuration 64 - */ 65 - static getConfig(): Readonly<DebugConfig> { 66 - return { ...this.config }; 67 - } 68 - 69 - // Category-specific debug methods 70 - static theme = (...args: unknown[]): void => { 71 - if (this.config.theme) console.log('🎨 [THEME]', ...args); 72 - }; 73 - 74 - static cache = (...args: unknown[]): void => { 75 - if (this.config.cache) console.log('💾 [CACHE]', ...args); 76 - }; 77 - 78 - static parsing = (...args: unknown[]): void => { 79 - if (this.config.parsing) console.log('📝 [PARSING]', ...args); 80 - }; 81 - 82 - static popup = (...args: unknown[]): void => { 83 - if (this.config.popup) console.log('🔧 [POPUP]', ...args); 84 - }; 85 - 86 - static serviceWorker = (...args: unknown[]): void => { 87 - if (this.config.serviceWorker) console.log('⚙️ [SW]', ...args); 88 - }; 89 - 90 - static transform = (...args: unknown[]): void => { 91 - if (this.config.transform) console.log('🔄 [TRANSFORM]', ...args); 92 - }; 93 - 94 - // Utility methods for common debugging patterns 95 - static error = (category: keyof DebugConfig, ...args: unknown[]): void => { 96 - if (this.config[category]) console.error(`❌ [${category.toUpperCase()}]`, ...args); 97 - }; 98 - 99 - static warn = (category: keyof DebugConfig, ...args: unknown[]): void => { 100 - if (this.config[category]) console.warn(`⚠️ [${category.toUpperCase()}]`, ...args); 101 - }; 102 - 103 - static time = (category: keyof DebugConfig, label: string): void => { 104 - if (this.config[category]) console.time(`⏱️ [${category.toUpperCase()}] ${label}`); 105 - }; 106 - 107 - static timeEnd = (category: keyof DebugConfig, label: string): void => { 108 - if (this.config[category]) console.timeEnd(`⏱️ [${category.toUpperCase()}] ${label}`); 109 - }; 110 - } 111 - 112 - /** 113 - * Debug log utility function that integrates with the Debug class 114 - * Used internally by logError and other functions 115 - */ 116 - export const debugLog = (category: string, level: string, ...args: unknown[]): void => { 117 - const categoryKey = category.toLowerCase() as keyof DebugConfig; 118 - if (Debug.getConfig()[categoryKey]) { 119 - console.log(`🔍 [${category}] ${level}:`, ...args); 120 - } 121 - }; 122 - 123 - /** 124 - * Centralized error logging function that handles both WormholeError and unknown errors 125 - * Provides structured logging for typed errors and fallback for unexpected errors 126 - */ 127 - export const logError = (category: string, error: unknown, context?: Record<string, unknown>): void => { 128 - const errorInfo = isWormholeError(error) ? error : { raw: String(error) }; 129 - 130 - // Format a readable error message 131 - let errorMessage = ''; 132 - if (isWormholeError(error)) { 133 - errorMessage = `${error.type}: ${error.message}`; 134 - if ('url' in error && error.url) { 135 - errorMessage += ` (URL: ${error.url})`; 136 - } 137 - if ('status' in error && error.status) { 138 - errorMessage += ` [Status: ${error.status}]`; 139 - } 140 - } else { 141 - errorMessage = String(error); 142 - } 143 - 144 - console.error(`❌ [${category}] ${errorMessage}`, context); 145 - 146 - if (import.meta.env.DEV) { 147 - debugLog(category, 'ERROR', errorInfo, context); 148 - } 149 - };
+15
src/shared/logging.ts
··· 1 + const isDev = import.meta.env.MODE === 'development'; 2 + 3 + export const debugLog = (category: string, ...args: unknown[]): void => { 4 + if (isDev) { 5 + console.log(`[${category}]`, ...args); 6 + } 7 + }; 8 + 9 + export const logError = (category: string, error: unknown, context?: Record<string, unknown>): void => { 10 + if (isDev) { 11 + console.error(`[${category}]`, error, context); 12 + } else { 13 + console.error(error); 14 + } 15 + };
+10 -14
src/shared/options.ts
··· 5 5 export interface WormholeOptions { 6 6 showEmojis: boolean; 7 7 strictMode: boolean; 8 + showCacheDebug: boolean; 8 9 } 9 10 10 11 // Option metadata ··· 25 26 key: 'strictMode', 26 27 defaultValue: false, 27 28 description: 'Only show services that support the current content type', 29 + }, 30 + showCacheDebug: { 31 + key: 'showCacheDebug', 32 + defaultValue: false, 33 + description: 'Display cache hit/miss info in the popup UI', 28 34 }, 29 35 }; 30 36 ··· 32 38 const DEFAULT_OPTIONS: WormholeOptions = { 33 39 showEmojis: true, 34 40 strictMode: false, 41 + showCacheDebug: false, 35 42 }; 36 43 37 44 // Get all options ··· 80 87 if ('strictMode' in changes) { 81 88 optionChanges.strictMode = changes.strictMode.newValue as boolean; 82 89 } 90 + if ('showCacheDebug' in changes) { 91 + optionChanges.showCacheDebug = changes.showCacheDebug.newValue as boolean; 92 + } 83 93 84 94 if (Object.keys(optionChanges).length > 0) { 85 95 callback(optionChanges); ··· 109 119 export function getOptionMetadata(): typeof OPTION_CONFIGS { 110 120 return OPTION_CONFIGS; 111 121 } 112 - 113 - // Legacy compatibility - backwards compatible wrapper for gradual migration 114 - export async function loadOptions(): Promise<WormholeOptions> { 115 - const result = await getOptions(); 116 - return result.match( 117 - (options) => options, 118 - () => DEFAULT_OPTIONS, 119 - ); 120 - } 121 - 122 - // Clear any legacy cache (no-op now since we don't cache) 123 - export function clearOptionsCache(): void { 124 - // No-op for compatibility 125 - }
+53 -2
src/shared/parser.ts
··· 2 2 import type { TransformInfo } from './types'; 3 3 import type { WormholeError } from './errors'; 4 4 import { parseError } from './errors'; 5 - import { parseUrlFromServices } from './services'; 5 + import { SERVICES } from './services'; 6 6 import { canonicalize } from './canonicalizer'; 7 - import { logError } from './debug'; 7 + import { logError } from './logging'; 8 8 9 9 /** 10 10 * Parses a raw input string (URL, DID, handle) and returns canonical info. ··· 72 72 }); 73 73 }); 74 74 } 75 + 76 + function parseUrlFromServices(url: URL): string | null { 77 + for (const service of Object.values(SERVICES)) { 78 + if (!service.parsing) continue; 79 + 80 + const hostnames = Array.isArray(service.parsing.hostname) ? service.parsing.hostname : [service.parsing.hostname]; 81 + if (!hostnames.includes(url.hostname)) continue; 82 + 83 + const patterns = service.parsing.patterns; 84 + if (!patterns) continue; 85 + 86 + if (patterns.customParser) { 87 + const result = patterns.customParser(url); 88 + if (result) return result; 89 + } 90 + 91 + if (patterns.queryParam) { 92 + const param = url.searchParams.get(patterns.queryParam); 93 + if (param && (param.startsWith('did:') || param.includes('.'))) { 94 + return param; 95 + } 96 + } 97 + 98 + if (patterns.profileIdentifier) { 99 + const match = url.pathname.match(patterns.profileIdentifier); 100 + if (match) { 101 + const identifier = match[1]; 102 + const restPath = url.pathname.slice(match[0].length); 103 + return restPath ? `${identifier}${restPath}` : identifier; 104 + } 105 + } 106 + 107 + if (patterns.profileHandle) { 108 + const match = url.pathname.match(patterns.profileHandle); 109 + if (match) { 110 + const handle = match[1]; 111 + const restPath = url.pathname.slice(match[0].length); 112 + return restPath ? `${handle}${restPath}` : handle; 113 + } 114 + } 115 + 116 + if (patterns.profileDid) { 117 + const match = url.pathname.match(patterns.profileDid); 118 + if (match) { 119 + return match[1]; 120 + } 121 + } 122 + } 123 + 124 + return null; 125 + }
+30 -2
src/shared/resolver.ts
··· 2 2 import { isRecord } from './types'; 3 3 import type { WormholeError } from './errors'; 4 4 import { networkError, parseError } from './errors'; 5 - import { logError } from './debug'; 6 - import { withNetworkRetry } from './retry'; 5 + import { logError } from './logging'; 7 6 8 7 /** 9 8 * Safely parse JSON and ensure it's an object using ResultAsync ··· 142 141 } 143 142 return `https://${hostAndPort}${path}/.well-known/did.json`; 144 143 } 144 + 145 + const NETWORK_RETRY_MAX_ATTEMPTS = 3; 146 + const NETWORK_RETRY_INITIAL_DELAY = 500; 147 + const NETWORK_RETRY_BACKOFF = 2.5; 148 + const NETWORK_RETRY_MAX_DELAY = 10000; 149 + 150 + const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); 151 + 152 + const shouldRetryNetworkError = (error: WormholeError): boolean => { 153 + if (error.type !== 'NETWORK_ERROR') return false; 154 + if (!error.status) return true; 155 + if (error.status >= 500) return true; 156 + return error.status === 429; 157 + }; 158 + 159 + function withNetworkRetry<T>(fn: () => ResultAsync<T, WormholeError>, attempt = 1): ResultAsync<T, WormholeError> { 160 + return fn().orElse((error) => { 161 + logError('RESOLVER', error, { attempt }); 162 + if (attempt >= NETWORK_RETRY_MAX_ATTEMPTS || !shouldRetryNetworkError(error)) { 163 + return err(error); 164 + } 165 + 166 + const exponentialDelay = NETWORK_RETRY_INITIAL_DELAY * Math.pow(NETWORK_RETRY_BACKOFF, attempt - 1); 167 + const delayWithJitter = exponentialDelay * (0.5 + Math.random() * 0.5); 168 + const delayMs = Math.min(delayWithJitter, NETWORK_RETRY_MAX_DELAY); 169 + 170 + return ResultAsync.fromPromise(delay(delayMs), () => error).andThen(() => withNetworkRetry(fn, attempt + 1)); 171 + }); 172 + }
-123
src/shared/retry.ts
··· 1 - /** 2 - * Network resilience utility with exponential backoff retry logic 3 - * Uses neverthrow's ResultAsync for functional error handling 4 - */ 5 - 6 - import { ResultAsync, err } from 'neverthrow'; 7 - import type { WormholeError } from './errors'; 8 - import { logError } from './debug'; 9 - 10 - interface RetryOptions { 11 - maxAttempts?: number; 12 - initialDelay?: number; 13 - maxDelay?: number; 14 - backoffFactor?: number; 15 - shouldRetry?: (error: WormholeError) => boolean; 16 - } 17 - 18 - const DEFAULT_OPTIONS: Required<RetryOptions> = { 19 - maxAttempts: 3, 20 - initialDelay: 100, // ms 21 - maxDelay: 5000, // ms 22 - backoffFactor: 2, 23 - shouldRetry: (error: WormholeError) => { 24 - // Only retry network errors, not parse/validation errors 25 - return error.type === 'NETWORK_ERROR' && (!error.status || error.status >= 500); 26 - }, 27 - }; 28 - 29 - /** 30 - * Utility function to create a delay promise 31 - */ 32 - const delay = (ms: number): Promise<void> => { 33 - return new Promise((resolve) => setTimeout(resolve, ms)); 34 - }; 35 - 36 - /** 37 - * Calculate the next delay using exponential backoff with jitter 38 - */ 39 - const calculateDelay = (attempt: number, options: Required<RetryOptions>): number => { 40 - const exponentialDelay = options.initialDelay * Math.pow(options.backoffFactor, attempt); 41 - const delayWithJitter = exponentialDelay * (0.5 + Math.random() * 0.5); // 50-100% of calculated delay 42 - return Math.min(delayWithJitter, options.maxDelay); 43 - }; 44 - 45 - /** 46 - * Retry a function with exponential backoff 47 - * 48 - * @param fn Function that returns a ResultAsync 49 - * @param options Retry configuration options 50 - * @returns ResultAsync with retry logic applied 51 - * 52 - * @example 53 - * ```typescript 54 - * const fetchWithRetry = withRetry( 55 - * () => fetchData('https://api.example.com'), 56 - * { maxAttempts: 3, initialDelay: 200 } 57 - * ); 58 - * 59 - * fetchWithRetry.match( 60 - * (data) => console.log('Success:', data), 61 - * (error) => console.error('Failed after retries:', error) 62 - * ); 63 - * ``` 64 - */ 65 - export function withRetry<T>( 66 - fn: () => ResultAsync<T, WormholeError>, 67 - options: RetryOptions = {}, 68 - ): ResultAsync<T, WormholeError> { 69 - const opts = { ...DEFAULT_OPTIONS, ...options }; 70 - 71 - const attemptWithRetry = (attempt: number): ResultAsync<T, WormholeError> => { 72 - return fn().orElse((error) => { 73 - // Log the attempt for debugging 74 - logError('RETRY', error, { attempt, maxAttempts: opts.maxAttempts }); 75 - 76 - // Check if we should retry 77 - if (attempt >= opts.maxAttempts || !opts.shouldRetry(error)) { 78 - return err(error); 79 - } 80 - 81 - // Calculate delay and retry 82 - const delayMs = calculateDelay(attempt - 1, opts); 83 - 84 - return ResultAsync.fromPromise( 85 - delay(delayMs), 86 - () => error, // This should never happen with delay 87 - ).andThen(() => attemptWithRetry(attempt + 1)); 88 - }); 89 - }; 90 - 91 - return attemptWithRetry(1); 92 - } 93 - 94 - /** 95 - * Specialized retry function for network requests with default network-optimized settings 96 - * 97 - * @param fn Function that returns a ResultAsync 98 - * @param customOptions Optional overrides for network-specific defaults 99 - * @returns ResultAsync with network retry logic applied 100 - */ 101 - export function withNetworkRetry<T>( 102 - fn: () => ResultAsync<T, WormholeError>, 103 - customOptions: Partial<RetryOptions> = {}, 104 - ): ResultAsync<T, WormholeError> { 105 - const networkDefaults: RetryOptions = { 106 - maxAttempts: 3, 107 - initialDelay: 500, // Higher initial delay for network requests 108 - maxDelay: 10000, // Higher max delay for network requests 109 - backoffFactor: 2.5, // More aggressive backoff for network 110 - shouldRetry: (error: WormholeError) => { 111 - if (error.type !== 'NETWORK_ERROR') return false; 112 - 113 - // Retry on 5xx errors and network failures (no status) 114 - if (!error.status) return true; // Network error without status (timeout, connection failed) 115 - if (error.status >= 500) return true; // Server errors 116 - if (error.status === 429) return true; // Rate limiting 117 - 118 - return false; // Don't retry 4xx client errors 119 - }, 120 - }; 121 - 122 - return withRetry(fn, { ...networkDefaults, ...customOptions }); 123 - }
-64
src/shared/services.ts
··· 270 270 }; 271 271 272 272 /** 273 - * Parses a URL using service configurations to extract AT Protocol identifiers. 274 - * Returns a string that can be passed to canonicalize() or null if no match. 275 - */ 276 - export function parseUrlFromServices(url: URL): string | null { 277 - for (const service of Object.values(SERVICES)) { 278 - if (!service.parsing) continue; 279 - 280 - // Check if hostname matches (support string or array) 281 - const hostnames = Array.isArray(service.parsing.hostname) ? service.parsing.hostname : [service.parsing.hostname]; 282 - 283 - if (!hostnames.includes(url.hostname)) continue; 284 - 285 - const patterns = service.parsing.patterns; 286 - if (!patterns) continue; 287 - 288 - // Try custom parser first (highest priority) 289 - if (patterns.customParser) { 290 - const result = patterns.customParser(url); 291 - if (result) return result; 292 - } 293 - 294 - // Try query parameter extraction 295 - if (patterns.queryParam) { 296 - const param = url.searchParams.get(patterns.queryParam); 297 - if (param && (param.startsWith('did:') || param.includes('.'))) { 298 - return param; 299 - } 300 - } 301 - 302 - // Try profile identifier (handle OR DID) 303 - if (patterns.profileIdentifier) { 304 - const match = url.pathname.match(patterns.profileIdentifier); 305 - if (match) { 306 - const identifier = match[1]; 307 - // Extract rest of path for posts/feeds/lists 308 - const restPath = url.pathname.slice(match[0].length); 309 - return restPath ? `${identifier}${restPath}` : identifier; 310 - } 311 - } 312 - 313 - // Try handle-specific pattern 314 - if (patterns.profileHandle) { 315 - const match = url.pathname.match(patterns.profileHandle); 316 - if (match) { 317 - const handle = match[1]; 318 - const restPath = url.pathname.slice(match[0].length); 319 - return restPath ? `${handle}${restPath}` : handle; 320 - } 321 - } 322 - 323 - // Try DID-specific pattern 324 - if (patterns.profileDid) { 325 - const match = url.pathname.match(patterns.profileDid); 326 - if (match) { 327 - const did = match[1]; 328 - return did; 329 - } 330 - } 331 - } 332 - 333 - return null; 334 - } 335 - 336 - /** 337 273 * Builds a list of destination link objects from canonical info using service configuration. 338 274 */ 339 275 export function buildDestinations(
+17 -45
src/shared/types.ts
··· 16 16 | { type: 'UPDATE_CACHE'; did: string; handle: string } 17 17 | { type: 'GET_HANDLE'; did: string } 18 18 | { type: 'GET_DID'; handle: string } 19 - | { type: 'CLEAR_CACHE' } 20 - | { type: 'DEBUG_LOG'; message: string }; 19 + | { type: 'CLEAR_CACHE' }; 21 20 22 - export interface ThemeColors { 23 - accentcolor?: string; 24 - textcolor?: string; 25 - toolbar?: string; 26 - toolbar_text?: string; 27 - toolbar_field?: string; 28 - toolbar_field_text?: string; 29 - toolbar_field_border?: string; 30 - popup?: string; 31 - popup_text?: string; 32 - popup_border?: string; 33 - popup_highlight?: string; 34 - popup_highlight_text?: string; 35 - button_background_hover?: string; 36 - button_background_active?: string; 21 + export interface Destination { 22 + url: string; 23 + label: string; 37 24 } 38 25 39 26 export interface BrowserTheme { 40 - colors?: ThemeColors; 27 + colors?: { 28 + popup?: string; 29 + popup_text?: string; 30 + popup_border?: string; 31 + popup_highlight?: string; 32 + popup_highlight_text?: string; 33 + toolbar?: string; 34 + toolbar_text?: string; 35 + toolbar_field?: string; 36 + toolbar_field_text?: string; 37 + toolbar_field_border?: string; 38 + button_background_hover?: string; 39 + }; 41 40 } 42 41 43 42 export interface BrowserThemeAPI { ··· 46 45 47 46 export interface BrowserWithTheme { 48 47 theme?: BrowserThemeAPI; 49 - } 50 - 51 - export interface Destination { 52 - url: string; 53 - label: string; 54 - } 55 - 56 - export interface DebugConfig { 57 - theme: boolean; 58 - cache: boolean; 59 - parsing: boolean; 60 - popup: boolean; 61 - serviceWorker: boolean; 62 - transform: boolean; 63 - } 64 - 65 - export interface WindowWithDebug extends Window { 66 - wormholeDebug: { 67 - theme: (enable: boolean) => void; 68 - cache: (enable: boolean) => void; 69 - parsing: (enable: boolean) => void; 70 - popup: (enable: boolean) => void; 71 - serviceWorker: (enable: boolean) => void; 72 - transform: (enable: boolean) => void; 73 - all: (enable: boolean) => void; 74 - getConfig: () => DebugConfig; 75 - }; 76 48 } 77 49 78 50 export function isRecord(x: unknown): x is Record<string, unknown> {
+3 -5
tests/cache.test.ts
··· 174 174 let mockStorage: MockStorage; 175 175 176 176 beforeEach(() => { 177 - cache = new DidHandleCache(); 177 + cache = new DidHandleCache({ persistDebounceMs: 0 }); 178 178 179 179 mockStorage = { 180 180 local: { ··· 289 289 // Error case - should not happen in this test 290 290 }, 291 291 ); 292 - 293 - await new Promise((resolve) => setTimeout(resolve, 10)); 294 292 295 293 cache.getHandle('did:plc:123'); 296 294 ··· 372 370 describe('LRU eviction', () => { 373 371 test('should handle size limits gracefully', async () => { 374 372 const maxSize = 1000; 375 - cache = new DidHandleCache(maxSize); 373 + cache = new DidHandleCache({ maxStorageSize: maxSize, persistDebounceMs: 0 }); 376 374 377 375 await cache.set('did:plc:test1', 'test1.bsky.social').match( 378 376 () => { ··· 406 404 407 405 test('should evict entries when manually triggered', async () => { 408 406 const maxSize = 100; 409 - cache = new DidHandleCache(maxSize); 407 + cache = new DidHandleCache({ maxStorageSize: maxSize, persistDebounceMs: 0 }); 410 408 411 409 await cache.set('did:plc:test1', 'test1.bsky.social').match( 412 410 () => {
+23 -34
tests/options.test.ts
··· 8 8 removeOptionsChangeListener, 9 9 getDefaultOptions, 10 10 getOptionMetadata, 11 - loadOptions, 12 - clearOptionsCache, 13 11 } from '../src/shared/options'; 14 12 import type { WormholeOptions } from '../src/shared/options'; 15 13 ··· 80 78 expect(result.value).toEqual({ 81 79 showEmojis: true, 82 80 strictMode: false, 81 + showCacheDebug: false, 83 82 }); 84 83 } 85 84 }); ··· 87 86 test('should return stored options', async () => { 88 87 mockStorageData.showEmojis = false; 89 88 mockStorageData.strictMode = true; 89 + mockStorageData.showCacheDebug = true; 90 90 91 91 const result = await getOptions(); 92 92 expect(result.isOk()).toBe(true); ··· 94 94 expect(result.value).toEqual({ 95 95 showEmojis: false, 96 96 strictMode: true, 97 + showCacheDebug: true, 97 98 }); 98 99 } 99 100 }); ··· 108 109 expect(result.value).toEqual({ 109 110 showEmojis: false, 110 111 strictMode: false, // default 112 + showCacheDebug: false, 111 113 }); 112 114 } 113 115 }); ··· 117 119 test('should return specific option value', async () => { 118 120 mockStorageData.showEmojis = false; 119 121 mockStorageData.strictMode = true; 122 + mockStorageData.showCacheDebug = true; 120 123 121 124 const result = await getOption('showEmojis'); 122 125 expect(result.isOk()).toBe(true); ··· 127 130 128 131 test('should return default value when option not set', async () => { 129 132 const result = await getOption('strictMode'); 133 + expect(result.isOk()).toBe(true); 134 + if (result.isOk()) { 135 + expect(result.value).toBe(false); 136 + } 137 + }); 138 + 139 + test('should return showCacheDebug default value', async () => { 140 + const result = await getOption('showCacheDebug'); 130 141 expect(result.isOk()).toBe(true); 131 142 if (result.isOk()) { 132 143 expect(result.value).toBe(false); ··· 139 150 const options: WormholeOptions = { 140 151 showEmojis: false, 141 152 strictMode: true, 153 + showCacheDebug: true, 142 154 }; 143 155 144 156 const result = await setOptions(options); 145 157 expect(result.isOk()).toBe(true); 146 158 expect(mockStorageData.showEmojis).toBe(false); 147 159 expect(mockStorageData.strictMode).toBe(true); 160 + expect(mockStorageData.showCacheDebug).toBe(true); 148 161 }); 149 162 150 163 test('should set partial options', async () => { ··· 152 165 expect(result.isOk()).toBe(true); 153 166 expect(mockStorageData.showEmojis).toBe(false); 154 167 expect(mockStorageData.strictMode).toBeUndefined(); 168 + expect(mockStorageData.showCacheDebug).toBeUndefined(); 155 169 }); 156 170 }); 157 171 158 172 describe('setOption', () => { 159 173 test('should set single option', async () => { 160 - const result = await setOption('showEmojis', false); 174 + const result = await setOption('showCacheDebug', true); 161 175 expect(result.isOk()).toBe(true); 162 - expect(mockStorageData.showEmojis).toBe(false); 176 + expect(mockStorageData.showCacheDebug).toBe(true); 163 177 }); 164 178 }); 165 179 ··· 239 253 expect(defaults).toEqual({ 240 254 showEmojis: true, 241 255 strictMode: false, 256 + showCacheDebug: false, 242 257 }); 243 258 }); 244 259 ··· 263 278 defaultValue: false, 264 279 description: 'Only show services that support the current content type', 265 280 }); 266 - }); 267 - }); 268 - 269 - describe('loadOptions (legacy)', () => { 270 - test('should return options on success', async () => { 271 - mockStorageData.showEmojis = false; 272 - const options = await loadOptions(); 273 - expect(options).toEqual({ 274 - showEmojis: false, 275 - strictMode: false, 281 + expect(metadata.showCacheDebug).toEqual({ 282 + key: 'showCacheDebug', 283 + defaultValue: false, 284 + description: 'Display cache hit/miss info in the popup UI', 276 285 }); 277 - }); 278 - 279 - test('should return defaults on error', async () => { 280 - // Simulate storage error 281 - mockChrome.storage.sync.get.mockImplementationOnce(() => { 282 - return Promise.reject(new Error('Storage error')); 283 - }); 284 - 285 - const options = await loadOptions(); 286 - expect(options).toEqual({ 287 - showEmojis: true, 288 - strictMode: false, 289 - }); 290 - }); 291 - }); 292 - 293 - describe('clearOptionsCache', () => { 294 - test('should be a no-op', () => { 295 - // Should not throw 296 - expect(() => clearOptionsCache()).not.toThrow(); 297 286 }); 298 287 }); 299 288 });