Skip to content
Merged
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
21 changes: 20 additions & 1 deletion dev/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? ""))
Expand All @@ -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();
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" });
Expand Down
119 changes: 119 additions & 0 deletions src/app/DeveloperSettings.tsx
Original file line number Diff line number Diff line change
@@ -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<BrokerStats | undefined> {
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<BrokerStats>();
const [clearing, setClearing] = useState(false);
const [status, setStatus] = useState<string>();

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 (
<section aria-labelledby="developer-settings-title">
<h2
id="developer-settings-title"
className="mt-0 mb-3 text-lg font-medium"
>
Developer
</h2>
<div className="ui-card space-y-5 p-5 sm:p-6">
<p className="text-sm text-muted">
Diagnostics for local development. This tab only appears when the app
is served from localhost in a development build.
</p>
<div className="space-y-2">
<h3 className="m-0 text-sm font-medium">Relay broker stats</h3>
{stats ? (
<dl className="m-0 grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-4">
<div>
<dt className="text-muted">Queries</dt>
<dd className="m-0 tabular-nums">{stats.queries}</dd>
</div>
<div>
<dt className="text-muted">Errors</dt>
<dd className="m-0 tabular-nums">{stats.errors}</dd>
</div>
<div>
<dt className="text-muted">Media</dt>
<dd className="m-0 tabular-nums">{stats.media}</dd>
</div>
<div>
<dt className="text-muted">Connects</dt>
<dd className="m-0 tabular-nums">{stats.connects}</dd>
</div>
</dl>
) : (
<p role="status" className="m-0 text-sm text-muted">
Broker stats are unavailable. They exist only when the dev relay
broker is running on this origin.
</p>
)}
</div>
<div className="space-y-2">
<h3 className="m-0 text-sm font-medium">Caches</h3>
<p className="m-0 text-sm text-muted">
Clears cached channels, messages, and media. Account, relay, and
sidebar settings are kept.
</p>
<button
type="button"
disabled={clearing}
onClick={() => void clearCache()}
>
{clearing ? "Clearing…" : "Clear cache"}
</button>
{status && (
<p role="status" className="m-0 text-sm text-muted">
{status}
</p>
)}
</div>
</div>
</section>
);
}
31 changes: 28 additions & 3 deletions src/app/Settings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -104,6 +124,11 @@ export function Settings({
<div hidden={selected !== "profile"}>
<ProfileSettings communities={communities} />
</div>
{developerMode && (
<div hidden={selected !== "developer"}>
<DeveloperSettings relay={communities.relay} />
</div>
)}
<div hidden={selected !== "plugins"}>
<section aria-labelledby="plugin-settings-title">
<h2
Expand Down
4 changes: 3 additions & 1 deletion src/app/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { communityDestination } from "../features/communities/destination";
import type { OpenTarget } from "../features/navigation/targets";
import type { PageNavigation } from "../features/navigation/service";
import type { OpenFailure } from "../features/navigation/controller";
import { developerMode } from "./Settings";

const channelsKey = "buzz.channels/channels";
export function useAppNavigation(services: AppServices) {
Expand Down Expand Up @@ -79,7 +80,8 @@ export function useAppNavigation(services: AppServices) {
target.section &&
!["profile", "plugins", "appearance", "notifications"].includes(
target.section,
)
) &&
!(developerMode && target.section === "developer")
)
failure = "unavailable";
const owner = useMemo(
Expand Down
10 changes: 10 additions & 0 deletions src/features/relay/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** Development diagnostics for the warm/preparation paths. Enable per browser
* with localStorage "buzz.debug.relay" = "1"; never active in other tabs or builds. */
export function relayDebug(...parts: readonly unknown[]): void {
try {
if (globalThis.localStorage?.getItem("buzz.debug.relay") !== "1") return;
} catch {
return;
}
console.info("[relay]", ...parts);
}
24 changes: 18 additions & 6 deletions src/features/relay/media.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { relayDebug } from "./debug";
/** Avatar request warming, the react-native-web Image model: fetch and
* decode() into detached images, then retain nothing. The browser's own HTTP
* and decoded-image caches serve the real <img> mounts. Never warms
Expand All @@ -8,6 +9,7 @@ export function createMediaPreparation() {
let disposed = false;
const active = new Set<HTMLImageElement>();
const cancellations = new Map<HTMLImageElement, () => void>();
const startedAt = new Map<string, number>();
let decoded = 0;
function pump() {
if (disposed || typeof Image === "undefined") return;
Expand All @@ -17,26 +19,33 @@ export function createMediaPreparation() {
const image = new Image();
active.add(image);
let finished = false;
const finish = () => {
const finish = (outcome: string) => {
if (finished) return;
finished = true;
clearTimeout(timeout);
image.onload = image.onerror = null;
active.delete(image);
cancellations.delete(image);
pending.delete(url);
relayDebug(
"avatar",
outcome,
url.slice(-28),
`${Date.now() - (startedAt.get(url) ?? Date.now())}ms`,
);
startedAt.delete(url);
pump();
};
const timeout = setTimeout(() => {
image.src = "";
finish();
finish("timeout");
}, 10000);
cancellations.set(image, () => finish());
image.onerror = () => finish();
cancellations.set(image, () => finish("cancelled"));
image.onerror = () => finish("error");
image.onload = () => {
// Do not explicitly decode enormous originals just to prepare an avatar.
if (image.naturalWidth * image.naturalHeight * 4 > 8 * 1024 * 1024) {
finish();
finish(`too-large ${image.naturalWidth}x${image.naturalHeight}`);
return;
}
void image
Expand All @@ -47,9 +56,12 @@ export function createMediaPreparation() {
},
() => {},
)
.finally(() => finish());
.finally(() =>
finish(`ok ${image.naturalWidth}x${image.naturalHeight}`),
);
};
image.referrerPolicy = "no-referrer";
startedAt.set(url, Date.now());
image.src = url;
}
}
Expand Down
Loading
Loading