diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index d3203a30..b6e56d46 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -355,6 +355,14 @@ export function relayBrokerPlugin({ ); server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith("/api/relay/")) return next(); + const startedAt = Date.now(); + const route = new URL(req.url, "http://localhost").pathname; + res.on("finish", () => { + if (route === "/api/relay/media") return; + server.config.logger.info( + `[relay-broker] ${req.method} ${route} -> ${res.statusCode} (${Date.now() - startedAt}ms)`, + ); + }); const origin = `http://${req.headers.host ?? ""}`; // Same-origin browser access only; trusted plugins/local processes are not sandboxed. if (!/^(localhost|127\.0\.0\.1):\d+$/.test(req.headers.host ?? "")) @@ -364,8 +372,12 @@ export function relayBrokerPlugin({ (req.headers.origin && req.headers.origin !== origin) || (req.headers["sec-fetch-site"] && req.headers["sec-fetch-site"] !== "same-origin") - ) + ) { + server.config.logger.info( + `[relay-broker] rejected ${req.method} ${route}: origin=${req.headers.origin ?? "none"} sec-fetch-site=${req.headers["sec-fetch-site"] ?? "none"}`, + ); return json(res, 403, { error: "Origin rejected" }); + } const url = new URL(req.url, origin); // Own cancellation before awaiting the request body, signing or dispatch. const cancel = new AbortController(); @@ -732,6 +744,9 @@ export function relayBrokerPlugin({ const bytes = Buffer.from(await upstream.arrayBuffer()); if (bytes.length > MAX_MEDIA_BYTES) return json(res, 413, { error: "Media budget exceeded" }); + server.config.logger.info( + `[relay-broker] media ${target.pathname} ${bytes.length}B ${type} (${Date.now() - startedAt}ms)`, + ); res.writeHead(200, { "Content-Type": type, "Cache-Control": "private, max-age=3600", @@ -923,6 +938,10 @@ export function relayBrokerPlugin({ !validFilters(filters) ) return json(res, 400, { error: "Read filter rejected" }); + if (route === "/api/relay/query") + server.config.logger.info( + `[relay-broker] query ${req.headers["x-buzz-read-priority"] === "background" ? "background" : "foreground"} ${JSON.stringify(filters).slice(0, 240)}`, + ); const gifSearchPath = gifs ? await getGifSearchPath(relay) : null; if (gifs && !gifSearchPath) return json(res, 404, { error: "GIF search is unavailable" }); diff --git a/src/app/DeveloperSettings.tsx b/src/app/DeveloperSettings.tsx new file mode 100644 index 00000000..8f5d34f5 --- /dev/null +++ b/src/app/DeveloperSettings.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; +import type { RelayData } from "../features/relay/service"; + +type BrokerStats = { + queries: number; + errors: number; + media: number; + connects: number; +}; + +const STATS_POLL_MS = 5000; + +/** Only the dev broker serves /api/relay/*. Packaged builds and proxied + * deployments have no such endpoint, so absence is normal, not an error. */ +async function fetchStats(): Promise { + try { + const res = await fetch("/api/relay/stats"); + if (!res.ok) return undefined; + return (await res.json()) as BrokerStats; + } catch { + return undefined; + } +} + +export function DeveloperSettings({ relay }: { relay: RelayData }) { + const [stats, setStats] = useState(); + const [clearing, setClearing] = useState(false); + const [status, setStatus] = useState(); + + useEffect(() => { + let alive = true; + async function poll() { + const next = await fetchStats(); + if (alive) setStats(next); + } + void poll(); + const timer = setInterval(poll, STATS_POLL_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, []); + + async function clearCache() { + setClearing(true); + try { + await relay.clearCache(); + setStatus("Caches cleared. Channels and media will refetch on demand."); + } catch (error) { + setStatus(`Clear failed: ${String(error)}`); + } finally { + setClearing(false); + } + } + + return ( +
+

+ Developer +

+
+

+ Diagnostics for local development. This tab only appears when the app + is served from localhost in a development build. +

+
+

Relay broker stats

+ {stats ? ( +
+
+
Queries
+
{stats.queries}
+
+
+
Errors
+
{stats.errors}
+
+
+
Media
+
{stats.media}
+
+
+
Connects
+
{stats.connects}
+
+
+ ) : ( +

+ Broker stats are unavailable. They exist only when the dev relay + broker is running on this origin. +

+ )} +
+
+

Caches

+

+ Clears cached channels, messages, and media. Account, relay, and + sidebar settings are kept. +

+ + {status && ( +

+ {status} +

+ )} +
+
+
+ ); +} diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 04a899d5..905710e4 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -1,6 +1,13 @@ import { useEffect, useState, useSyncExternalStore } from "react"; import { RecoveryScreen } from "./RecoveryScreen"; -import { Blocks, Settings2, UserRound, Palette, Bell } from "lucide-react"; +import { + Blocks, + Settings2, + UserRound, + Palette, + Bell, + Wrench, +} from "lucide-react"; import type { PluginManager } from "../plugins/manager"; import type { Communities } from "../features/communities/service"; import { PluginImport } from "./PluginImport"; @@ -10,13 +17,26 @@ import type { Appearance } from "../shared/theme/service"; import { AppearanceSettings } from "./AppearanceSettings"; import { NotificationSettings } from "./NotificationSettings"; import type { NotificationsService } from "../features/notifications/service"; +import { DeveloperSettings } from "./DeveloperSettings"; -const sections = [ +type Section = { id: string; label: string; icon: typeof UserRound }; + +const baseSections: Section[] = [ { id: "profile", label: "Profile", icon: UserRound }, { id: "plugins", label: "Plugins", icon: Blocks }, { id: "appearance", label: "Appearance", icon: Palette }, { id: "notifications", label: "Notifications", icon: Bell }, -] as const; +]; + +// DEV alone is not enough: packaged desktop builds load a production bundle +// from tauri://localhost, so the hostname check excludes them too. +export const developerMode = + import.meta.env.DEV && + /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname); + +const sections: Section[] = developerMode + ? [...baseSections, { id: "developer", label: "Developer", icon: Wrench }] + : baseSections; export function Settings({ plugins, @@ -104,6 +124,11 @@ export function Settings({ + {developerMode && ( + + )}