Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules
dist
dist-firefox
.vite
.DS_Store
*.log
send-to-thebrain-*.zip
*.xpi
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Send to TheBrain",
"version": "0.1.1",
"version": "0.1.2",
"description": "Save the current page as a thought in your Brain.",
"action": {
"default_popup": "src/popup/index.html",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "send-to-thebrain",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"type": "module",
"description": "Chrome browser extension that saves the active page into TheBrain via the desktop app's local HTTP API.",
Expand Down
26 changes: 23 additions & 3 deletions src/api/TheBrainLocalClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,30 @@ export interface TheBrainLocalClientOptions {
/** Either the bare server origin (`http://localhost:52341`) or the URL shown
* in the desktop app's Local API widget (`http://localhost:52341/api/`). */
endpoint: string;
/** Per-request timeout in milliseconds. A short bound is essential because
* TheBrain's desktop app picks a fresh listening port every session, so the
* saved endpoint frequently points at a stale port. Without a timeout, the
* popup can sit on a TCP RST/half-open socket for tens of seconds. */
timeoutMs?: number;
}

export const DEFAULT_REQUEST_TIMEOUT_MS = 4000;

export class TheBrainLocalClient {
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly timeoutMs: number;

constructor({ apiKey, endpoint }: TheBrainLocalClientOptions) {
constructor({ apiKey, endpoint, timeoutMs }: TheBrainLocalClientOptions) {
this.apiKey = apiKey;
this.baseUrl = normalizeEndpoint(endpoint);
this.timeoutMs = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
}

/** The normalized origin currently in use (e.g. `http://localhost:52341`).
* Exposed so callers can record the working port after a successful call. */
getBaseUrl(): string {
return this.baseUrl;
}

private async request<T>(
Expand All @@ -40,7 +55,11 @@ export class TheBrainLocalClient {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
};
const init: RequestInit = { method, headers };
const init: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(this.timeoutMs),
};
if(body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
Expand All @@ -50,7 +69,8 @@ export class TheBrainLocalClient {
try {
response = await fetch(url, init);
} catch {
// TypeError from fetch when the local server isn't reachable.
// TypeError from fetch when the local server isn't reachable, or
// AbortError when our timeout fires (e.g. stale port).
throw new NotRunningError();
}

Expand Down
123 changes: 123 additions & 0 deletions src/lib/portDiscovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect, vi } from "vitest";
import {
buildCandidates,
discoverEndpoint,
portFromEndpoint,
DEFAULT_FALLBACK_PORTS,
} from "./portDiscovery";

describe("portFromEndpoint", () => {
it("extracts the port from a normalized endpoint", () => {
expect(portFromEndpoint("http://localhost:52341")).toBe(52341);
});
it("works with the trailing /api form", () => {
expect(portFromEndpoint("http://localhost:8001/api/")).toBe(8001);
});
it("returns null when no explicit port is present", () => {
expect(portFromEndpoint("http://localhost/api")).toBeNull();
});
it("returns null for unparseable input", () => {
expect(portFromEndpoint("not a url")).toBeNull();
});
});

describe("buildCandidates", () => {
it("starts with the current endpoint, then MRU, then fallbacks", () => {
const out = buildCandidates("http://localhost:52341/api/", [8001, 9000]);
const ports = out.map((c) => c.port);
expect(ports[0]).toBe(52341);
expect(ports.slice(1, 3)).toEqual([8001, 9000]);
// Defaults follow, with 8001 already deduped.
expect(ports).toContain(DEFAULT_FALLBACK_PORTS[1]);
});
it("dedupes when the current port is also in the MRU", () => {
const out = buildCandidates("http://localhost:52341", [52341, 8001]);
const ports = out.map((c) => c.port);
expect(ports.filter((p) => p === 52341)).toHaveLength(1);
expect(ports[0]).toBe(52341);
expect(ports[1]).toBe(8001);
});
it("falls back to defaults when nothing else is known", () => {
const out = buildCandidates("", []);
expect(out.map((c) => c.port)).toEqual([...DEFAULT_FALLBACK_PORTS]);
});
it("ignores invalid ports", () => {
const out = buildCandidates("http://localhost:99999", [-1, 0, 70000]);
expect(out.every((c) => c.port > 0 && c.port < 65536)).toBe(true);
});
});

describe("discoverEndpoint", () => {
it("returns the first ok candidate in candidate order", async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if(url.startsWith("http://localhost:8001/")) {
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
}
if(url.startsWith("http://localhost:9000/")) {
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
}
throw new TypeError("connection refused");
});
const result = await discoverEndpoint({
apiKey: "k",
candidates: [
{ origin: "http://localhost:52341", port: 52341 },
{ origin: "http://localhost:8001", port: 8001 },
{ origin: "http://localhost:9000", port: 9000 },
],
timeoutMs: 100,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(result.winner?.port).toBe(8001);
expect(result.all).toHaveLength(3);
});

it("reports auth when the port responds with 401 but no port is OK", async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if(url.startsWith("http://localhost:52341/")) {
return new Response("nope", { status: 401 });
}
throw new TypeError("connection refused");
});
const result = await discoverEndpoint({
apiKey: "k",
candidates: [
{ origin: "http://localhost:8001", port: 8001 },
{ origin: "http://localhost:52341", port: 52341 },
],
timeoutMs: 100,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(result.winner).toBeNull();
expect(result.all.some((r) => r.kind === "auth" && r.port === 52341)).toBe(true);
});

it("returns null winner when every probe fails", async () => {
const fetchImpl = vi.fn(async () => {
throw new TypeError("connection refused");
});
const result = await discoverEndpoint({
apiKey: "k",
candidates: [
{ origin: "http://localhost:8001", port: 8001 },
{ origin: "http://localhost:9000", port: 9000 },
],
timeoutMs: 100,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(result.winner).toBeNull();
expect(result.all.every((r) => r.kind === "no")).toBe(true);
});

it("handles an empty candidate list", async () => {
const result = await discoverEndpoint({
apiKey: "k",
candidates: [],
fetchImpl: vi.fn() as unknown as typeof fetch,
});
expect(result.winner).toBeNull();
expect(result.all).toEqual([]);
});
});
130 changes: 130 additions & 0 deletions src/lib/portDiscovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Probe localhost ports in parallel to find the one TheBrain's desktop app
// is currently listening on. Browser extensions can't enumerate processes
// (no equivalent of `Get-NetTCPConnection`), so the only signal available
// is "does an HTTP request to localhost:<port>/api/app/state succeed".

import { normalizeEndpoint } from "./endpoint";

/** Origin to probe and the port we extracted from it (handy for callers
* that want to record a successful port in the MRU list). */
export interface PortCandidate {
origin: string;
port: number;
}

/** Outcome for a single probe. `ok` means "TheBrain answered"; `auth` means
* the port has a TheBrain instance but the saved API key was rejected
* (probably stale). `no` means nothing usable on that port. */
export type ProbeResult =
| { kind: "ok"; origin: string; port: number }
| { kind: "auth"; origin: string; port: number }
| { kind: "no"; origin: string; port: number };

export interface DiscoveryOptions {
apiKey: string;
candidates: PortCandidate[];
/** Per-probe timeout in ms. Total wall time is bounded by this because
* all probes run in parallel. Default 700ms keeps the popup snappy. */
timeoutMs?: number;
/** Injected for tests. Falls back to global fetch. */
fetchImpl?: typeof fetch;
}

export interface DiscoveryResult {
/** First "ok" probe — the endpoint to use. Null if none answered. */
winner: ProbeResult | null;
/** All results, in the order they settled. Useful for diagnostics
* (e.g. to surface "key was rejected on port X" if no ok). */
all: ProbeResult[];
}

const DEFAULT_PROBE_TIMEOUT_MS = 700;
// Default ports to try when the user has no history yet. 8001 is the
// observed default for self-hosted local APIs; 52341 is what TheBrain's
// desktop widget often shows; 8081 is a frequent collision-avoidance pick.
export const DEFAULT_FALLBACK_PORTS: readonly number[] = [8001, 52341, 8081];

/** Build the candidate list from settings + defaults, deduplicating while
* preserving order: current endpoint first, then MRU history, then
* hard-coded fallbacks. */
export function buildCandidates(
currentEndpoint: string,
recentPorts: readonly number[],
fallbacks: readonly number[] = DEFAULT_FALLBACK_PORTS,
): PortCandidate[] {
const seen = new Set<number>();
const out: PortCandidate[] = [];
const add = (port: number) => {
if(!Number.isInteger(port) || port <= 0 || port >= 65536) return;
if(seen.has(port)) return;
seen.add(port);
out.push({ origin: `http://localhost:${port}`, port });
};
const currentPort = portFromEndpoint(currentEndpoint);
if(currentPort !== null) add(currentPort);
for(const p of recentPorts) add(p);
for(const p of fallbacks) add(p);
return out;
}

/** Pull the port out of an endpoint string. Returns null if the string
* isn't a parseable URL or has no explicit port. */
export function portFromEndpoint(endpoint: string): number | null {
const normalized = normalizeEndpoint(endpoint);
if(!normalized) return null;
try {
const url = new URL(normalized);
if(!url.port) return null;
const n = Number(url.port);
return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
} catch {
return null;
}
}

/** Probe every candidate in parallel. Resolves once all probes have
* settled (success, failure, or timeout). The caller gets the first
* "ok" result if any; otherwise can inspect `all` for "auth" hits. */
export async function discoverEndpoint(opts: DiscoveryOptions): Promise<DiscoveryResult> {
const { apiKey, candidates } = opts;
const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
const fetchImpl = opts.fetchImpl ?? fetch;

if(candidates.length === 0) {
return { winner: null, all: [] };
}

const probes = candidates.map((c) => probeOne(c, apiKey, timeoutMs, fetchImpl));
const all = await Promise.all(probes);
// Pick winner in candidate order so "current endpoint" or top of MRU
// wins ties — this is the user's expected behavior, not "whichever
// race we won by 2ms".
const winner = all.find((r) => r.kind === "ok") ?? null;
return { winner, all };
}

async function probeOne(
candidate: PortCandidate,
apiKey: string,
timeoutMs: number,
fetchImpl: typeof fetch,
): Promise<ProbeResult> {
const url = `${candidate.origin}/api/app/state`;
try {
const response = await fetchImpl(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(timeoutMs),
});
if(response.ok) {
return { kind: "ok", origin: candidate.origin, port: candidate.port };
}
if(response.status === 401 || response.status === 403) {
return { kind: "auth", origin: candidate.origin, port: candidate.port };
}
return { kind: "no", origin: candidate.origin, port: candidate.port };
} catch {
// Connection refused / RST / our timeout fired.
return { kind: "no", origin: candidate.origin, port: candidate.port };
}
}
27 changes: 27 additions & 0 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ export interface Settings {
// YouTube's ?v=VIDEO_ID). Entries are bare hostnames; subdomains match.
trimQueryParamsExceptions: string[];
autoProceed: boolean;
// Most-recently-successful local API ports, MRU-ordered. Used by the
// port-discovery probe to find TheBrain after the desktop app rotates
// to a new port across sessions.
recentPorts: number[];
}

export const AUTO_PROCEED_MS = 3000;

export const RECENT_PORTS_LIMIT = 8;

export const DEFAULT_TRIM_EXCEPTIONS: readonly string[] = [
"youtube.com",
"youtu.be",
Expand All @@ -34,6 +40,7 @@ const DEFAULTS: Settings = {
trimQueryParams: false,
trimQueryParamsExceptions: [...DEFAULT_TRIM_EXCEPTIONS],
autoProceed: false,
recentPorts: [],
};

const KEYS: (keyof Settings)[] = [
Expand All @@ -44,6 +51,7 @@ const KEYS: (keyof Settings)[] = [
"trimQueryParams",
"trimQueryParamsExceptions",
"autoProceed",
"recentPorts",
];

export async function getSettings(): Promise<Settings> {
Expand Down Expand Up @@ -72,9 +80,28 @@ export async function getSettings(): Promise<Settings> {
typeof stored.autoProceed === "boolean"
? stored.autoProceed
: DEFAULTS.autoProceed,
recentPorts: Array.isArray(stored.recentPorts)
? (stored.recentPorts as unknown[])
.filter(
(v): v is number =>
typeof v === "number" &&
Number.isInteger(v) &&
v > 0 &&
v < 65536,
)
.slice(0, RECENT_PORTS_LIMIT)
: [...DEFAULTS.recentPorts],
};
}

// Push a port to the front of the recent-ports MRU list, deduping and
// capping the length. Returns the new list (not persisted — the caller
// passes it to updateSettings).
export function pushRecentPort(existing: number[], port: number): number[] {
const filtered = existing.filter((p) => p !== port);
return [port, ...filtered].slice(0, RECENT_PORTS_LIMIT);
}

// Parse a free-form list (newlines or commas) into normalized hostnames.
// Strips scheme/path/whitespace and lowercases. Used by the options UI.
export function parseExceptionList(input: string): string[] {
Expand Down
Loading