Design: Browser Extension for Go-Links Navigation
Context
joe-links is a self-hosted go-links service accessed via a single-word hostname (go). All
modern browsers treat single-word address-bar entries as search queries, not URLs, before
attempting DNS resolution. This makes go/foo open a search rather than navigate.
📝 ADR-0012 chose a Manifest V3 Web Extension as the solution. This document describes how the extension is built.
Goals / Non-Goals
Goals
- Intercept search navigations that match registered keyword patterns and redirect to the go-links server
- Support Chrome natively (unpacked load); support Safari via
xcrun safari-web-extension-converter; support Firefox viaabout:debuggingtemporary add-on load - Dynamically discover keyword hosts from the server (supporting 📝 ADR-0011 keyword forwarding)
- Authenticate server requests with an optional API key (Personal Access Token)
- Provide a browser action popup for quick link creation from the current tab
- Remain invisible unless a go-link pattern is detected (zero interference with normal browsing)
Non-Goals
- Omnibox / keyword-search UX (changes typing UX from
go/footogo foo; rejected) - Packaging or publishing to Chrome Web Store, Firefox Add-ons, or Safari App Store (manual install only)
- Full link management UI within the extension (edit, delete, list) — only creation via popup
Decisions
Interception Mechanism: webNavigation + tabs.update
Choice: Use chrome.webNavigation.onBeforeNavigate to detect navigations to search engine
URLs whose q parameter matches a keyword pattern, then call chrome.tabs.update() to
redirect the tab.
Rationale: declarativeNetRequest redirect rules are powerful but require static or
regex-based rules to be registered ahead of time. Dynamically updating rules per-keyword
(via updateDynamicRules) is possible but complex. The webNavigation + tabs.update
approach is simpler: the service worker holds the keyword list in memory and checks patterns
imperatively. The redirect happens before the search page renders (onBeforeNavigate fires
pre-commit).
Alternatives considered:
declarativeNetRequestwith dynamic rules: viable but overengineered for this use case; regex substitution in redirect rules has cross-browser inconsistenciesomniboxAPI: changes the typing UX togo+ Tab + slug; rejected (notgo/foo)- Manifest V2
webRequest.onBeforeRequestblocking: not available in MV3
Pattern Matching
Choice: A navigation is a go-link navigation if and only if the decoded q parameter
of the search URL matches /^([a-z][a-z0-9-]*)\/(.+)$/ AND the captured keyword is in the
registered keyword set.
Rationale: This is an exact match on the full query — the user typed only go/foo, nothing
else. A query like how to use go/defer won't match because the full query contains spaces and
doesn't start with a keyword. This keeps false-positive interceptions at zero.
Search Engine Detection
Choice: Maintain a hardcoded list of known search engine URL patterns to check against:
Google (google.com/search), Bing (bing.com/search), DuckDuckGo (duckduckgo.com/),
Yahoo (search.yahoo.com). Match on hostname + path prefix.
Rationale: We can only intercept navigations the browser makes. When the user types
go/foo, the browser navigates to https://google.com/search?q=go%2Ffoo (or equivalent for
other search engines). We detect this by recognising the destination as a known search engine.
Keyword Storage and Refresh
Choice: Keywords are stored in chrome.storage.local. The service worker fetches
{baseURL}/api/v1/keywords at install time and every 60 minutes using chrome.alarms.
The canonical host is always present regardless of API results.
Rationale: chrome.storage.local persists across service worker restarts (MV3 service
workers are ephemeral). Alarms survive service worker restarts too, making periodic refresh
reliable without keeping the service worker alive.
API Key Authentication
Choice: Store an optional API key in chrome.storage.local alongside baseURL. When
present, attach it as an Authorization: Bearer {key} header on all outbound requests to
the joe-links server (keyword discovery, link creation). When absent, requests are sent
without authentication.
Rationale: The joe-links server supports Personal Access Tokens (SPEC-0006, 📝 ADR-0009)
with Authorization: Bearer headers. Reusing the same auth mechanism keeps the extension
simple — no OAuth flow, no token refresh, no cookie management. The user creates a PAT in
the joe-links dashboard and pastes it into the extension options page. This also means the
extension never handles user credentials directly.
Alternatives considered:
- OAuth2/OIDC flow from the extension: overly complex for a browser extension; requires redirect handling, token storage, and refresh logic
- Cookie-based auth: would require the extension to maintain a session with the server, which is fragile across service worker restarts in MV3
Browser Action Popup for Link Creation
Choice: Add a popup.html browser action that lets users create short links. The popup
pre-fills the current tab's URL via chrome.tabs.query, provides slug and optional keyword
fields, and POSTs to {baseURL}/api/v1/links with the stored API key.
Rationale: A popup is the simplest way to expose link creation without navigating away
from the current page. Pre-filling the URL reduces friction to a single field (the slug).
The popup is stateless — it reads config from chrome.storage.local on open and makes a
single API call on submit.
Alternatives considered:
- Content script with floating UI: invasive, risks style conflicts with host pages
- Side panel: requires additional permissions and is not supported in all browsers
- Options page with creation form: too many clicks; the popup is one click away
On-Install Setup Flow
Choice: In the chrome.runtime.onInstalled handler, check chrome.storage.local for
a saved baseURL. If none exists (fresh install), open the options page in a new tab via
chrome.runtime.openOptionsPage().
Rationale: Without a configured server URL, the extension cannot function. Opening the options page on first install guides the user to configure the minimum required settings (base URL and optionally an API key) before the extension can intercept any navigations.
Architecture
File Structure
extension/
├── manifest.json # MV3 manifest
├── background.js # Service worker: interception + keyword refresh + on-install setup
├── options.html # Options page UI (base URL + API key)
├── options.js # Options page logic
├── popup.html # Browser action popup UI (create link)
├── popup.js # Popup logic (pre-fill URL, POST to API)
└── icons/
├── icon-16.png
├── icon-48.png
└── icon-128.png
manifest.json declares:
manifest_version: 3background.service_worker: "background.js"permissions: ["storage", "tabs", "webNavigation", "alarms", "activeTab"]host_permissions: ["http://go/*", "<all_urls>"](all_urls needed for search engine matching)options_ui.page: "options.html"action.default_popup: "popup.html"browser_specific_settings.gecko.idandgecko.strict_min_versionfor Firefox
background.js responsibilities:
- On install/startup: load keywords from storage, schedule alarm
- On alarm: fetch
/api/v1/keywords, update storage - On
webNavigation.onBeforeNavigate: check URL against search engine list, decodeq, test against keyword set, calltabs.updateif match
options.html / options.js: form to read/write baseURL and apiKey in
chrome.storage.local, with URL validation before saving.
popup.html / popup.js: browser action popup for creating short links. On open,
queries chrome.tabs.query for the active tab URL and pre-fills the destination field.
Submits via POST to {baseURL}/api/v1/links with Authorization: Bearer {apiKey} header.
Displays success (created slug) or error message.
Risks / Trade-offs
- MV3 service worker ephemerality: Service workers terminate when idle. State (keyword list)
must be re-loaded from
chrome.storage.localat the start of eachwebNavigationhandler invocation, not assumed to be in memory. The alarm re-fires the worker periodically. - Search engine list maintenance: If a user's default search engine is not in our hardcoded list (e.g., Kagi, Brave Search), interception won't work. The options page should allow adding custom search engine patterns in a future iteration.
- Safari conversion caveats:
xcrun safari-web-extension-converterproduces a native macOS app wrapper. The user must build it in Xcode and enable the extension in Safari settings. This is a one-time setup but more involved than Chrome's unpacked load. http://redirect over plain HTTP: The extension redirects tohttp://go/foo, which is plain HTTP. This is intentional for local/intranet use. Production deployments behind HTTPS would need the base URL configured accordingly in the options page.
Open Questions
- Should the extension support a per-keyword base URL override (e.g.,
wtf→ server A,gh→ server B), or is a single configured server sufficient? - Should the
options.htmlinclude a "Test" button that verifies connectivity to the configured server?