feat(p2p settings): add browser p2p stats

This commit is contained in:
Tommaso Casaburi
2026-05-07 16:56:29 +07:00
parent 2ebd9ecc30
commit 14a4790c8b
51 changed files with 1457 additions and 249 deletions
@@ -8,6 +8,18 @@ import SettingsModal from '../settings-modal';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: {
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
} as Record<string, any>,
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -42,6 +54,10 @@ vi.mock('../subscriptions-setting', () => ({
default: () => <div data-testid='subscriptions-settings-panel'>subscriptions-settings</div>,
}));
vi.mock('../p2p-stats-settings', () => ({
default: () => <div data-testid='p2p-stats-settings-panel'>p2p-stats-settings</div>,
}));
const LocationProbe = () => {
const location = useLocation();
return <div data-testid='location'>{location.pathname + location.hash}</div>;
@@ -134,6 +150,7 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull();
const collapseAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) =>
(candidate.textContent ?? '').includes('collapse_all_settings'),
@@ -151,6 +168,13 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="account-settings"]')).toBeNull();
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).toBeNull();
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).toBeNull();
expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).toBeNull();
});
it('opens the p2p stats section from its hash', () => {
render('/all/settings#p2p-stats-settings');
expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull();
});
it('closes the modal when the overlay is clicked', async () => {
@@ -84,6 +84,7 @@ const clickButton = async (text: string) => {
describe('AdvancedSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
testState.account = {
mediaIpfsGatewayUrl: 'https://media.old.example',
chainProviders: {
@@ -192,6 +193,66 @@ describe('AdvancedSettings', () => {
expect(textInputs[2]?.value).toBe('/tmp/connected-node');
});
it('saves the browser pure p2p toggle through advanced settings', async () => {
await renderSettings(false);
const checkbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox?.checked).toBe(false);
expect(container.textContent).not.toContain('pure P2P:');
expect(checkbox?.closest('label')?.nextElementSibling?.textContent).toBe('enable_pure_p2p_tip');
await act(async () => {
checkbox?.click();
});
await clickButton('save_advanced_settings');
expect(testState.setAccountMock).toHaveBeenCalledWith(
expect.objectContaining({
pkcOptions: expect.objectContaining({
httpRoutersOptions: ['https://router.old.example'],
ipfsGatewayUrls: undefined,
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
pkcRpcClientsOptions: undefined,
pubsubKuboRpcClientsOptions: undefined,
}),
}),
);
expect(localStorage.getItem('5chan:pure-p2p-browser-enabled')).toBe('true');
expect(reloadMock).toHaveBeenCalledOnce();
});
it('saves gateway mode defaults when browser pure p2p is disabled', async () => {
testState.account = {
mediaIpfsGatewayUrl: 'https://media.old.example',
pkcOptions: {
httpRoutersOptions: ['https://peers.pleb.bot'],
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
};
await renderSettings(false);
const checkbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox?.checked).toBe(true);
await act(async () => {
checkbox?.click();
});
await clickButton('save_advanced_settings');
expect(testState.setAccountMock).toHaveBeenCalledWith(
expect.objectContaining({
pkcOptions: expect.objectContaining({
httpRoutersOptions: ['https://peers.pleb.bot'],
ipfsGatewayUrls: ['https://ipfsgateway.xyz', 'https://gateway.plebpubsub.xyz', 'https://gateway.forumindex.com'],
libp2pJsClientsOptions: undefined,
pubsubKuboRpcClientsOptions: ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0'],
}),
}),
);
expect(localStorage.getItem('5chan:pure-p2p-browser-enabled')).toBe('false');
});
it('toggles the node rpc instructions panel', async () => {
await renderSettings(false);
@@ -78,6 +78,19 @@
padding-left: 1px;
}
.pureP2PSettings {
margin-bottom: 15px;
}
.pureP2PSettings .settingTip {
margin: 2px 0 5px 0;
padding-left: 19px;
}
.pureP2PCheckbox {
margin-right: 5px;
}
.p2pRPCSettings button {
margin-left: 5px;
}
@@ -1,6 +1,8 @@
import { memo, RefObject, useRef, useState } from 'react';
import { setAccount, useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
import { useTranslation } from 'react-i18next';
import { getBrowserGatewayPkcOptions, getBrowserPureP2PPkcOptions, setPureP2PBrowserPreference } from '../../../lib/p2p-browser-config';
import { canConfigureBrowserPureP2P, isBrowserPureP2PEnabled } from '../../../lib/p2p-runtime';
import styles from './advanced-settings.module.css';
interface SettingsProps {
@@ -12,6 +14,7 @@ interface SettingsProps {
solRpcRef?: RefObject<HTMLTextAreaElement>;
p2pRpcRef?: RefObject<HTMLInputElement>;
p2pDataPathRef?: RefObject<HTMLInputElement>;
pureP2PBrowserRef?: RefObject<HTMLInputElement>;
}
type AccountProtocolOptions = {
@@ -19,6 +22,8 @@ type AccountProtocolOptions = {
dataPath?: string;
httpRoutersOptions?: string[];
ipfsGatewayUrls?: string[];
kuboRpcClientsOptions?: unknown[];
libp2pJsClientsOptions?: unknown[];
pkcRpcClientsOptions?: string[];
pubsubHttpClientsOptions?: string[];
pubsubKuboRpcClientsOptions?: string[];
@@ -210,6 +215,21 @@ const P2pDataPathSettings = ({ p2pDataPathRef }: SettingsProps) => {
);
};
const PureP2PBrowserSettings = ({ pureP2PBrowserRef }: SettingsProps) => {
const { t } = useTranslation();
const account = useAccount() as AccountShape | undefined;
return (
<div className={styles.pureP2PSettings}>
<label>
<input className={styles.pureP2PCheckbox} type='checkbox' defaultChecked={isBrowserPureP2PEnabled(account)} ref={pureP2PBrowserRef} />
{t('enable_pure_p2p')}
</label>
<div className={styles.settingTip}>{t('enable_pure_p2p_tip')}</div>
</div>
);
};
const isElectron = window.electronApi?.isElectron === true;
const AdvancedSettings = () => {
@@ -225,6 +245,7 @@ const AdvancedSettings = () => {
const httpRoutersRef = useRef<HTMLTextAreaElement>(null);
const p2pRpcRef = useRef<HTMLInputElement>(null);
const p2pDataPathRef = useRef<HTMLInputElement>(null);
const pureP2PBrowserRef = useRef<HTMLInputElement>(null);
const handleSave = async () => {
const ipfsGatewayUrls = ipfsGatewayUrlsRef.current?.value
@@ -256,6 +277,7 @@ const AdvancedSettings = () => {
const pkcRpcClientsOptions = p2pRpcRef.current?.value.trim() ? [p2pRpcRef.current.value.trim()] : undefined;
const dataPath = p2pDataPathRef.current?.value.trim() || undefined;
const pureP2PBrowserPreference = canConfigureBrowserPureP2P() ? pureP2PBrowserRef.current?.checked : undefined;
const chainProviders: Record<string, { urls: string[] | undefined; chainId: number }> = {};
if (ethRpcUrls && ethRpcUrls.length > 0) {
@@ -265,20 +287,44 @@ const AdvancedSettings = () => {
chainProviders.sol = { urls: solRpcUrls, chainId: 101 };
}
let pkcOptions: AccountProtocolOptions = {
...protocolOptions,
ipfsGatewayUrls,
pubsubKuboRpcClientsOptions,
httpRoutersOptions,
pkcRpcClientsOptions,
dataPath,
};
if (pureP2PBrowserPreference !== undefined) {
if (pureP2PBrowserPreference) {
const pureP2POptions = getBrowserPureP2PPkcOptions();
pkcOptions = {
...pkcOptions,
...pureP2POptions,
httpRoutersOptions: httpRoutersOptions?.length ? httpRoutersOptions : pureP2POptions.httpRoutersOptions,
pkcRpcClientsOptions: undefined,
};
} else {
const gatewayOptions = getBrowserGatewayPkcOptions();
pkcOptions = {
...pkcOptions,
...gatewayOptions,
ipfsGatewayUrls: ipfsGatewayUrls?.length ? ipfsGatewayUrls : gatewayOptions.ipfsGatewayUrls,
pubsubKuboRpcClientsOptions: pubsubKuboRpcClientsOptions?.length ? pubsubKuboRpcClientsOptions : gatewayOptions.pubsubKuboRpcClientsOptions,
httpRoutersOptions: httpRoutersOptions?.length ? httpRoutersOptions : gatewayOptions.httpRoutersOptions,
};
}
}
try {
await setAccount({
...account,
mediaIpfsGatewayUrl,
chainProviders,
pkcOptions: {
...protocolOptions,
ipfsGatewayUrls,
pubsubKuboRpcClientsOptions,
httpRoutersOptions,
pkcRpcClientsOptions,
dataPath,
},
pkcOptions,
});
if (pureP2PBrowserPreference !== undefined) setPureP2PBrowserPreference(pureP2PBrowserPreference);
alert('Options saved, reloading...');
window.location.reload();
} catch (e) {
@@ -293,6 +339,7 @@ const AdvancedSettings = () => {
return (
<div className={styles.content}>
{canConfigureBrowserPureP2P() && <PureP2PBrowserSettings pureP2PBrowserRef={pureP2PBrowserRef} />}
<div className={styles.category}>
<span className={styles.categoryTitle}>IPFS gateways:</span>
<span className={styles.categorySettings}>
@@ -0,0 +1,226 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: {} as Record<string, any>,
rpcSettings: { state: 'disconnected' } as Record<string, any>,
setAccountMock: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
setAccount: (account: unknown) => testState.setAccountMock(account),
useAccount: () => testState.account,
usePkcRpcSettings: () => testState.rpcSettings,
}));
let container: HTMLDivElement;
let root: Root;
const loadComponent = async (isElectron = false) => {
vi.resetModules();
window.electronApi = isElectron ? ({ isElectron: true } as Window['electronApi']) : undefined;
window.isElectron = isElectron;
return (await import('../p2p-stats-settings')).default;
};
const renderSettings = async (isElectron = false) => {
const P2PStatsSettings = await loadComponent(isElectron);
await act(async () => {
root.render(createElement(P2PStatsSettings));
await Promise.resolve();
});
};
describe('P2PStatsSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
window.electronApi = undefined;
window.isElectron = false;
testState.account = {
id: 'account-1',
author: { address: 'author', wallets: {} },
pkcOptions: {
ipfsGatewayUrls: ['https://gateway.example'],
},
};
testState.rpcSettings = { state: 'disconnected' };
testState.setAccountMock.mockReset().mockResolvedValue(undefined);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders browser libp2p stats from the active PKC client', async () => {
testState.account = {
...testState.account,
pkcOptions: {
httpRoutersOptions: ['https://router.example'],
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
pkc: {
clients: {
libp2pJsClients: {
libp2pjs: {
key: 'libp2pjs',
heliaWithKuboRpcClientFunctions: {
add: async () => {
throw new Error("Helia 'add' is not supported at the moment in pkc-js API");
},
},
_helia: {
libp2p: {
getConnections: () => ['connection-1'],
getMultiaddrs: () => ['/ip4/127.0.0.1/tcp/4001'],
getPeers: () => ['peer-1', 'peer-2'],
metrics: {
toJSON: () => ({
helia_bitswap_data_received_bytes: { global: 2048, peer1: 2048 },
helia_bitswap_sent_data_bytes_total: 1024,
}),
},
peerId: { toString: () => 'self-peer' },
services: {
pubsub: {
getPeers: () => ['peer-1'],
},
},
},
routing: {
routers: [
{
async provide() {
// noop
},
},
],
},
},
},
},
},
},
};
await renderSettings(false);
await act(async () => Promise.resolve());
expect(container.textContent).toContain('leeching');
expect(container.textContent).not.toContain('browser Helia');
expect(container.textContent).not.toContain('seed mode');
expect(container.textContent).not.toContain('status');
expect(container.textContent).toContain('self-peer');
expect(container.textContent).toContain('2 peers');
expect(container.textContent).toContain('downloaded data');
expect(container.textContent).toContain('2.00 KB');
expect(container.textContent).toContain('uploaded data');
expect(container.textContent).toContain('1.00 KB');
expect(container.textContent).not.toContain('pubsub topics');
expect(container.textContent).not.toContain('topic subscribers');
});
it('reads browser transfer counters from Helia bitswap ledgers', async () => {
testState.account = {
...testState.account,
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
pkc: {
clients: {
libp2pJsClients: {
libp2pjs: {
key: 'libp2pjs',
_helia: {
blockstore: {
child: {
blockBrokers: [
{
bitswap: {
peerWantLists: {
ledgerMap: new Map([['peer-1', { bytesReceived: 4096, bytesSent: 2048 }]]),
},
},
},
],
},
},
libp2p: {
getConnections: () => [],
getMultiaddrs: () => [],
getPeers: () => [],
peerId: { toString: () => 'self-peer' },
},
},
},
},
},
},
};
await renderSettings(false);
await act(async () => Promise.resolve());
expect(container.textContent).toContain('downloaded data');
expect(container.textContent).toContain('4.00 KB');
expect(container.textContent).toContain('uploaded data');
expect(container.textContent).toContain('2.00 KB');
});
it('reports seeding only when browser Helia can add and publish provider records', async () => {
testState.account = {
...testState.account,
pkcOptions: {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
},
pkc: {
clients: {
libp2pJsClients: {
libp2pjs: {
key: 'libp2pjs',
heliaWithKuboRpcClientFunctions: {
add: async () => ({ cid: 'cid' }),
},
_helia: {
libp2p: {
getConnections: () => [],
getMultiaddrs: () => [],
getPeers: () => [],
peerId: { toString: () => 'self-peer' },
},
routing: {
routers: [
{
provide: async (cid: unknown) => cid,
},
],
},
},
},
},
},
},
};
await renderSettings(false);
await act(async () => Promise.resolve());
expect(container.textContent).toContain('seeding');
expect(container.textContent).not.toContain('seed mode');
});
});
@@ -0,0 +1 @@
export { default } from './p2p-stats-settings';
@@ -0,0 +1,54 @@
.content {
margin-left: 10px;
}
.setting {
margin-bottom: 15px;
}
.settingTip {
display: block;
font-size: 0.85em;
margin: 4px 0 0 20px;
}
.stats {
margin: 6px 0 0;
width: calc(100% - 8px);
border-collapse: collapse;
}
.stats th {
text-align: left;
font-weight: 700;
}
.stats th,
.stats td {
padding: 2px 4px 3px 0;
vertical-align: top;
}
.statName {
width: 34%;
text-transform: capitalize;
white-space: nowrap;
}
.statValue {
word-break: break-word;
}
.statsMeta {
margin-top: 5px;
font-size: 0.85em;
}
.statsMeta button {
margin-left: 5px;
}
.error {
color: var(--error-text-color, #b00020);
word-break: break-word;
}
@@ -0,0 +1,434 @@
import { memo, useEffect, useReducer } from 'react';
import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
import { useTranslation } from 'react-i18next';
import { getP2PRuntimeMode, type P2PRuntimeMode } from '../../../lib/p2p-runtime';
import styles from './p2p-stats-settings.module.css';
type AccountShape = Record<string, any>;
type StatRow = {
name: string;
value: string;
};
type StatsState = {
error?: string;
loading: boolean;
rows: StatRow[];
updatedAt?: number;
};
type StatsAction =
| {
type: 'loading';
}
| {
rows: StatRow[];
timestamp: number;
type: 'loaded';
}
| {
error: string;
timestamp: number;
type: 'failed';
};
type Libp2pClientShape = {
_helia?: {
libp2p?: {
getConnections?: () => unknown[] | Promise<unknown[]>;
getMultiaddrs?: () => unknown[] | Promise<unknown[]>;
getPeers?: () => unknown[] | Promise<unknown[]>;
peerId?: { toString: () => string };
services?: {
pubsub?: {
getPeers?: () => unknown[] | Promise<unknown[]>;
};
};
metrics?: unknown;
};
metrics?: unknown;
routing?: {
routers?: unknown[];
};
};
heliaWithKuboRpcClientFunctions?: {
add?: unknown;
};
key?: string;
};
type TransferStats = {
downloadedBytes?: number;
uploadedBytes?: number;
};
const KUBO_API_URL = 'http://localhost:50019/api/v0';
const STATS_REFRESH_MS = 5000;
const MAX_TRANSFER_COUNTER_DEPTH = 10;
const MAX_TRANSFER_COUNTER_OBJECTS = 400;
const statsReducer = (state: StatsState, action: StatsAction): StatsState => {
if (action.type === 'loading') return { ...state, error: undefined, loading: true };
if (action.type === 'loaded') return { loading: false, rows: action.rows, updatedAt: action.timestamp };
return { error: action.error, loading: false, rows: [], updatedAt: action.timestamp };
};
const getErrorMessage = (error: unknown, fallback = 'Error') => (error instanceof Error ? error.message : String(error || fallback));
const formatCount = (count: number, singular: string, plural = `${singular}s`) => `${count} ${count === 1 ? singular : plural}`;
const formatBytes = (value: unknown) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) return String(value ?? 'unknown');
if (numericValue < 1024) return `${numericValue} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let size = numericValue / 1024;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
};
const formatRate = (value: unknown) => `${formatBytes(value)}/s`;
const stringifyList = (items?: unknown[], maxItems = 5) => {
if (!items?.length) return 'none';
const values = items.map((item) => String(item));
const visibleValues = values.slice(0, maxItems);
return values.length > maxItems ? `${visibleValues.join(', ')} +${values.length - maxItems} more` : visibleValues.join(', ');
};
const getFirstObjectValue = <T,>(value?: Record<string, T>) => (value ? Object.values(value)[0] : undefined);
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object';
const getFiniteNumber = (value: unknown) => {
if (value === null || value === undefined) return undefined;
const numericValue = typeof value === 'bigint' ? Number(value) : Number(value);
return Number.isFinite(numericValue) ? numericValue : undefined;
};
const addTransferStats = (stats: TransferStats, direction: keyof TransferStats, value: unknown) => {
const numericValue = getFiniteNumber(value);
if (numericValue === undefined) return;
stats[direction] = (stats[direction] ?? 0) + numericValue;
};
const getEntries = (value: unknown): [string, unknown][] => {
try {
if (value instanceof Map) return Array.from(value.entries()).map(([key, entry]) => [String(key), entry]);
if (Array.isArray(value)) return value.map((entry, index) => [String(index), entry]);
if (isRecord(value)) return Object.entries(value);
return [];
} catch {
return [];
}
};
const toArray = (value: unknown): unknown[] => {
if (Array.isArray(value)) return value;
if (value && typeof value === 'object' && Symbol.iterator in value) return Array.from(value as Iterable<unknown>);
return [];
};
const getSafeArray = async (getValue?: () => unknown[] | Promise<unknown[]> | undefined): Promise<unknown[]> => {
try {
return toArray(getValue ? await getValue() : undefined);
} catch {
return [];
}
};
const getTransferStatsFromHeliaCounters = (helia: unknown): TransferStats => {
const stats: TransferStats = {};
const visited = new WeakSet<object>();
let objectsVisited = 0;
const visit = (value: unknown, depth: number) => {
try {
if (!isRecord(value) || visited.has(value) || depth > MAX_TRANSFER_COUNTER_DEPTH || objectsVisited > MAX_TRANSFER_COUNTER_OBJECTS) return;
visited.add(value);
objectsVisited++;
if ('bytesReceived' in value || 'bytesSent' in value) {
addTransferStats(stats, 'downloadedBytes', value.bytesReceived);
addTransferStats(stats, 'uploadedBytes', value.bytesSent);
}
for (const [key, entry] of getEntries(value)) {
if (typeof entry === 'function' || key === 'logger' || key === 'log' || key === 'events' || key === 'datastore' || key === 'routing') continue;
visit(entry, depth + 1);
}
} catch {
return;
}
};
visit(helia, 0);
return stats;
};
const classifyTransferMetricPath = (path: string[]) => {
const normalizedPath = path
.join('_')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
if (normalizedPath.includes('rate')) return undefined;
if (
normalizedPath.includes('totalin') ||
normalizedPath.includes('bytesreceived') ||
normalizedPath.includes('receivedbytes') ||
normalizedPath.includes('datareceivedbytes')
) {
return 'downloadedBytes' as const;
}
if (
normalizedPath.includes('totalout') ||
normalizedPath.includes('bytessent') ||
normalizedPath.includes('sentbytes') ||
normalizedPath.includes('datasentbytes') ||
normalizedPath.includes('sentdatabytes')
) {
return 'uploadedBytes' as const;
}
return undefined;
};
const getMetricSnapshot = async (source: unknown) => {
if (!isRecord(source)) return source;
for (const method of ['getMetrics', 'getMetricValues', 'toJSON']) {
const candidate = source[method];
if (typeof candidate !== 'function') continue;
try {
const snapshot = await candidate.call(source);
if (snapshot !== undefined) return snapshot;
} catch {
return undefined;
}
}
return source;
};
const getTransferStatsFromMetricSnapshot = (snapshot: unknown): TransferStats => {
const stats: TransferStats = {};
const visited = new WeakSet<object>();
const visit = (value: unknown, path: string[], depth: number) => {
const direction = classifyTransferMetricPath(path);
const numericValue = getFiniteNumber(value);
if (direction && numericValue !== undefined) {
addTransferStats(stats, direction, numericValue);
return;
}
if (!isRecord(value) || visited.has(value) || depth > MAX_TRANSFER_COUNTER_DEPTH) return;
visited.add(value);
if (direction && 'global' in value) {
addTransferStats(stats, direction, value.global);
return;
}
if (direction && 'value' in value) {
addTransferStats(stats, direction, value.value);
return;
}
for (const [key, entry] of getEntries(value)) visit(entry, [...path, key], depth + 1);
};
visit(snapshot, [], 0);
return stats;
};
const mergeTransferStats = (primary: TransferStats, fallback: TransferStats): TransferStats => ({
downloadedBytes: primary.downloadedBytes ?? fallback.downloadedBytes,
uploadedBytes: primary.uploadedBytes ?? fallback.uploadedBytes,
});
const getBrowserTransferStats = async (client?: Libp2pClientShape): Promise<TransferStats> => {
try {
const helia = client?._helia;
const counterStats = getTransferStatsFromHeliaCounters(helia);
const metricSources = [helia?.metrics, helia?.libp2p?.metrics].filter(Boolean);
let metricStats: TransferStats = {};
for (const source of metricSources) {
metricStats = mergeTransferStats(metricStats, getTransferStatsFromMetricSnapshot(await getMetricSnapshot(source)));
}
return mergeTransferStats(counterStats, metricStats);
} catch {
return {};
}
};
const getFunctionSource = (value: unknown) => {
if (typeof value !== 'function') return undefined;
try {
return Function.prototype.toString.call(value).toLowerCase();
} catch {
return undefined;
}
};
const hasSupportedAdd = (client?: Libp2pClientShape) => {
const add = client?.heliaWithKuboRpcClientFunctions?.add;
const source = getFunctionSource(add);
return typeof add === 'function' && !source?.includes('not supported') && !source?.includes('unsupported');
};
const isKnownNoopProvide = (provide: unknown) => {
const source = getFunctionSource(provide);
if (typeof provide !== 'function') return true;
return Boolean(source?.includes('noop') || source?.replace(/\s/g, '') === 'asyncprovide(){}');
};
const hasProviderPublishingRouter = (client?: Libp2pClientShape) =>
(client?._helia?.routing?.routers ?? []).some((router) => isRecord(router) && !isKnownNoopProvide(router.provide));
const getBrowserMode = (client?: Libp2pClientShape) => {
if (!client) return 'unknown';
return hasSupportedAdd(client) && hasProviderPublishingRouter(client) ? 'seeding' : 'leeching';
};
const getBrowserLibp2pStats = async (account?: AccountShape): Promise<StatRow[]> => {
const client = getFirstObjectValue(account?.pkc?.clients?.libp2pJsClients) as Libp2pClientShape | undefined;
const libp2p = client?._helia?.libp2p;
const pubsub = libp2p?.services?.pubsub;
const [peers, connections, multiaddrs, pubsubPeers, transferStats] = await Promise.all([
getSafeArray(() => libp2p?.getPeers?.()),
getSafeArray(() => libp2p?.getConnections?.()),
getSafeArray(() => libp2p?.getMultiaddrs?.()),
getSafeArray(() => pubsub?.getPeers?.()),
getBrowserTransferStats(client),
]);
return [
{ name: 'mode', value: getBrowserMode(client) },
{ name: 'peer id', value: libp2p?.peerId?.toString() ?? 'unknown' },
{ name: 'client key', value: client?.key ?? 'unknown' },
{ name: 'connected peers', value: formatCount(peers.length, 'peer') },
{ name: 'connections', value: formatCount(connections.length, 'connection') },
{ name: 'downloaded data', value: transferStats.downloadedBytes === undefined ? 'unknown' : formatBytes(transferStats.downloadedBytes) },
{ name: 'uploaded data', value: transferStats.uploadedBytes === undefined ? 'unknown' : formatBytes(transferStats.uploadedBytes) },
{ name: 'listen addresses', value: stringifyList(multiaddrs) },
{ name: 'pubsub peers', value: formatCount(pubsubPeers.length, 'peer') },
{ name: 'routers', value: stringifyList(account?.pkcOptions?.httpRoutersOptions) },
];
};
const kuboPostJson = async (path: string, params?: Record<string, string | boolean>, signal?: AbortSignal) => {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params ?? {})) searchParams.set(key, String(value));
const query = searchParams.toString();
const response = await fetch(`${KUBO_API_URL}/${path}${query ? `?${query}` : ''}`, { method: 'POST', signal });
if (!response.ok) throw new Error(`Kubo ${path} returned ${response.status}`);
const text = await response.text();
const firstJsonLine = text
.split('\n')
.map((line) => line.trim())
.find(Boolean);
return firstJsonLine ? JSON.parse(firstJsonLine) : {};
};
const getElectronKuboStats = async (rpcState?: string, signal?: AbortSignal): Promise<StatRow[]> => {
const [identity, version, peers, bandwidth, repo, bitswap] = await Promise.all([
kuboPostJson('id', undefined, signal),
kuboPostJson('version', undefined, signal),
kuboPostJson('swarm/peers', { direction: true, latency: true, streams: true }, signal),
kuboPostJson('stats/bw', undefined, signal),
kuboPostJson('repo/stat', undefined, signal),
kuboPostJson('bitswap/stat', undefined, signal),
]);
return [
{ name: 'mode', value: 'desktop Kubo' },
{ name: 'PKC RPC', value: rpcState ?? 'unknown' },
{ name: 'peer id', value: identity.ID ?? 'unknown' },
{ name: 'agent', value: identity.AgentVersion ?? version.Version ?? 'unknown' },
{ name: 'connected peers', value: formatCount(Array.isArray(peers.Peers) ? peers.Peers.length : 0, 'peer') },
{ name: 'bandwidth in', value: `${formatBytes(bandwidth.TotalIn)} total, ${formatRate(bandwidth.RateIn)}` },
{ name: 'bandwidth out', value: `${formatBytes(bandwidth.TotalOut)} total, ${formatRate(bandwidth.RateOut)}` },
{ name: 'repo size', value: formatBytes(repo.RepoSize) },
{ name: 'repo objects', value: String(repo.NumObjects ?? 'unknown') },
{ name: 'bitswap peers', value: formatCount(Array.isArray(bitswap.Peers) ? bitswap.Peers.length : 0, 'peer') },
{ name: 'bitswap wantlist', value: formatCount(Array.isArray(bitswap.Wantlist) ? bitswap.Wantlist.length : 0, 'item') },
];
};
const getP2PStats = async (mode: P2PRuntimeMode, account?: AccountShape, rpcState?: string, signal?: AbortSignal) => {
if (mode === 'browser-libp2p') return getBrowserLibp2pStats(account);
return getElectronKuboStats(rpcState, signal);
};
const P2PStatsSettings = () => {
const { t } = useTranslation();
const account = useAccount() as AccountShape | undefined;
const pkcRpcSettings = usePkcRpcSettings();
const mode = getP2PRuntimeMode(account);
const [statsState, dispatchStats] = useReducer(statsReducer, { loading: !!mode, rows: [] });
const updatedAtLabel = statsState.updatedAt ? new Date(statsState.updatedAt).toLocaleTimeString() : undefined;
useEffect(() => {
const abortController = new AbortController();
const { signal } = abortController;
const activeMode = mode;
const rpcState = pkcRpcSettings?.state;
if (!activeMode) return () => abortController.abort();
const refreshStats = async () => {
dispatchStats({ type: 'loading' });
try {
const rows = await getP2PStats(activeMode, account, rpcState, signal);
if (!signal.aborted) dispatchStats({ rows, timestamp: Date.now(), type: 'loaded' });
} catch (error) {
if (!signal.aborted) {
dispatchStats({
error: getErrorMessage(error),
timestamp: Date.now(),
type: 'failed',
});
}
}
};
void refreshStats();
const intervalId = window.setInterval(refreshStats, STATS_REFRESH_MS);
return () => {
abortController.abort();
window.clearInterval(intervalId);
};
}, [account, mode, pkcRpcSettings?.state]);
return (
<div className={styles.content} data-testid='p2p-stats-settings-panel'>
{mode ? (
<>
<table className={styles.stats}>
<tbody>
{statsState.rows.map((row) => (
<tr key={row.name}>
<td className={styles.statName}>{row.name}</td>
<td className={styles.statValue}>{row.value}</td>
</tr>
))}
</tbody>
</table>
<div className={styles.statsMeta}>
{statsState.loading ? t('p2p_stats_loading') : updatedAtLabel ? `${t('p2p_stats_updated')} ${updatedAtLabel}` : null}
{statsState.error && <div className={styles.error}>{statsState.error}</div>}
</div>
</>
) : (
<div className={styles.statsMeta}>{t('p2p_stats_starting')}</div>
)}
</div>
);
};
export default memo(P2PStatsSettings);
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAccount } from '@bitsocial/bitsocial-react-hooks';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import styles from './settings-modal.module.css';
@@ -9,21 +10,25 @@ import InterfaceSettings from './interface-settings';
import MediaHostingSettings from './media-hosting-settings';
import AdvancedSettings from './advanced-settings';
import SubscriptionsSetting from './subscriptions-setting';
import P2PStatsSettings from './p2p-stats-settings';
import { P2P_STATS_SECTION_ID, shouldShowP2PSettingsSection } from '../../lib/p2p-runtime';
const allSectionIds = ['interface-settings', 'media-hosting-settings', 'account-settings', 'subscriptions-settings', 'advanced-settings'];
const hashToSection = (hash: string): string | null => {
const hashToSection = (hash: string, sectionIds = allSectionIds): string | null => {
if (hash === 'crypto-address-settings' || hash === 'crypto-wallet-settings') return 'account-settings';
if (allSectionIds.includes(hash)) return hash;
if (sectionIds.includes(hash)) return hash;
return null;
};
const SettingsModal = () => {
const { t } = useTranslation();
const account = useAccount();
const location = useLocation();
const navigate = useNavigate();
const hash = location.hash.slice(1);
const hashSection = hashToSection(hash);
const sectionIds = useMemo(() => (shouldShowP2PSettingsSection(account) ? [...allSectionIds, P2P_STATS_SECTION_ID] : allSectionIds), [account]);
const hashSection = hashToSection(hash, sectionIds);
const closeModal = useCallback(() => {
const newPath = location.pathname.replace(/\/settings$/, '');
@@ -59,8 +64,9 @@ const SettingsModal = () => {
const showAccountSettings = visibleExpandedSections.has('account-settings');
const showSubscriptionsSettings = visibleExpandedSections.has('subscriptions-settings');
const showAdvancedSettings = visibleExpandedSections.has('advanced-settings');
const showP2PStatsSettings = visibleExpandedSections.has(P2P_STATS_SECTION_ID);
const allExpanded = useMemo(() => allSectionIds.every((id) => visibleExpandedSections.has(id)), [visibleExpandedSections]);
const allExpanded = useMemo(() => sectionIds.every((id) => visibleExpandedSections.has(id)), [sectionIds, visibleExpandedSections]);
const basePath = location.pathname;
@@ -89,7 +95,7 @@ const SettingsModal = () => {
setExpandedSections(new Set());
navigate(basePath, { replace: true });
} else {
setExpandedSections(new Set(allSectionIds));
setExpandedSections(new Set(sectionIds));
navigate(basePath, { replace: true });
}
};
@@ -169,6 +175,17 @@ const SettingsModal = () => {
</label>
</div>
{showAdvancedSettings && <AdvancedSettings />}
{sectionIds.includes(P2P_STATS_SECTION_ID) && (
<>
<div id={P2P_STATS_SECTION_ID} className={`${styles.setting} ${styles.category}`}>
<label onClick={() => handleCategoryClick(P2P_STATS_SECTION_ID)}>
<span className={showP2PStatsSettings ? styles.hideButton : styles.showButton} />
{t('p2p_stats')}
</label>
</div>
{showP2PStatsSettings && <P2PStatsSettings />}
</>
)}
</div>
</>
);
+70 -8
View File
@@ -1,6 +1,21 @@
import { describe, expect, it } from 'vitest';
import { configureP2PBrowserPkcOptions, isP2PBrowserHostname, P2P_BROWSER_PKC_OPTIONS } from '../p2p-browser-config';
import {
configureP2PBrowserPkcOptions,
getPureP2PBrowserPreference,
isP2PBrowserHostname,
P2P_BROWSER_PKC_OPTIONS,
PURE_P2P_BROWSER_SETTING_KEY,
setPureP2PBrowserPreference,
shouldUsePureP2PBrowser,
} from '../p2p-browser-config';
const createStorage = (values: Record<string, string | undefined> = {}) => ({
getItem: (key: string) => values[key] ?? null,
setItem: (key: string, value: string) => {
values[key] = value;
},
});
describe('p2p-browser-config', () => {
it('detects p2p subdomains', () => {
@@ -10,31 +25,78 @@ describe('p2p-browser-config', () => {
expect(isP2PBrowserHostname('www.p2p.5chan.app')).toBe(false);
});
it('configures browser PKC options for p2p hostnames', () => {
it('leaves browser PKC options untouched by default', () => {
const targetWindow = {
location: { hostname: 'p2p.5chan.app' },
location: { hostname: '5chan.app' },
localStorage: createStorage(),
defaultPkcOptions: {
ipfsGatewayUrls: ['https://gateway.example'],
},
};
expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(false);
expect(targetWindow.defaultPkcOptions).toEqual({
ipfsGatewayUrls: ['https://gateway.example'],
});
});
it('configures browser PKC options when pure p2p is enabled', () => {
const targetWindow = {
location: { hostname: '5chan.app' },
localStorage: createStorage({ [PURE_P2P_BROWSER_SETTING_KEY]: 'true' }),
defaultPkcOptions: {
ipfsGatewayUrls: ['https://gateway.example'],
},
};
expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(true);
expect(targetWindow.defaultPkcOptions).toEqual({
ipfsGatewayUrls: ['https://gateway.example'],
...P2P_BROWSER_PKC_OPTIONS,
});
expect(targetWindow.defaultPkcOptions).toEqual(P2P_BROWSER_PKC_OPTIONS);
});
it('leaves normal hostnames untouched', () => {
it('leaves browser PKC options untouched when pure p2p is disabled', () => {
const defaultPkcOptions = {
ipfsGatewayUrls: ['https://gateway.example'],
};
const targetWindow = {
location: { hostname: '5chan.app' },
localStorage: createStorage({ [PURE_P2P_BROWSER_SETTING_KEY]: 'false' }),
defaultPkcOptions,
};
expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(false);
expect(targetWindow.defaultPkcOptions).toBe(defaultPkcOptions);
});
it('leaves electron defaults untouched', () => {
const defaultPkcOptions = {
pkcRpcClientsOptions: ['ws://localhost:9138'],
};
const targetWindow = {
electronApi: { isElectron: true },
location: { hostname: 'localhost' },
localStorage: createStorage(),
defaultPkcOptions,
};
expect(configureP2PBrowserPkcOptions(targetWindow)).toBe(false);
expect(targetWindow.defaultPkcOptions).toBe(defaultPkcOptions);
});
it('persists and reads the browser pure p2p preference', () => {
const targetWindow = {
location: { hostname: '5chan.app' },
localStorage: createStorage(),
};
expect(getPureP2PBrowserPreference(targetWindow)).toBeUndefined();
expect(shouldUsePureP2PBrowser(targetWindow)).toBe(false);
setPureP2PBrowserPreference(false, targetWindow);
expect(getPureP2PBrowserPreference(targetWindow)).toBe(false);
expect(shouldUsePureP2PBrowser(targetWindow)).toBe(false);
setPureP2PBrowserPreference(true, targetWindow);
expect(getPureP2PBrowserPreference(targetWindow)).toBe(true);
expect(shouldUsePureP2PBrowser(targetWindow)).toBe(true);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
getBrowserGatewayAccountOptions,
getBrowserPureP2PAccountOptions,
getP2PRuntimeMode,
isBrowserPureP2PEnabled,
shouldShowP2PSettingsSection,
} from '../p2p-runtime';
const browserWindow = {
electronApi: undefined,
isElectron: false,
localStorage: {
getItem: () => null,
setItem: () => undefined,
},
} as unknown as Window;
const electronWindow = {
electronApi: { isElectron: true },
isElectron: true,
} as unknown as Window;
describe('p2p-runtime', () => {
it('detects browser libp2p accounts from options and live clients', () => {
expect(getP2PRuntimeMode({ pkcOptions: { libp2pJsClientsOptions: [{ key: 'libp2pjs' }] } }, browserWindow)).toBe('browser-libp2p');
expect(getP2PRuntimeMode({ pkc: { clients: { libp2pJsClients: { libp2pjs: {} } } } }, browserWindow)).toBe('browser-libp2p');
});
it('detects electron Kubo RPC accounts only in electron runtime', () => {
const account = { pkcOptions: { pkcRpcClientsOptions: ['ws://localhost:9138'] } };
expect(getP2PRuntimeMode(account, electronWindow)).toBe('electron-kubo-rpc');
expect(getP2PRuntimeMode(account, browserWindow)).toBeNull();
});
it('shows p2p settings in browsers only when pure p2p is enabled or active', () => {
expect(shouldShowP2PSettingsSection(undefined, browserWindow)).toBe(false);
expect(shouldShowP2PSettingsSection({ pkcOptions: { ipfsGatewayUrls: ['https://gateway.example'] } }, browserWindow)).toBe(false);
expect(isBrowserPureP2PEnabled({ pkcOptions: { ipfsGatewayUrls: ['https://gateway.example'] } }, browserWindow)).toBe(false);
});
it('builds browser p2p and gateway account options without a direct pkc-js import', () => {
const account = {
pkcOptions: {
httpRoutersOptions: ['https://custom-router.example'],
ipfsGatewayUrls: ['https://gateway.example'],
pkcRpcClientsOptions: ['ws://remote.example'],
},
};
expect(getBrowserPureP2PAccountOptions(account)).toMatchObject({
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
ipfsGatewayUrls: undefined,
pkcRpcClientsOptions: undefined,
});
expect(getBrowserGatewayAccountOptions(account)).toMatchObject({
ipfsGatewayUrls: ['https://ipfsgateway.xyz', 'https://gateway.plebpubsub.xyz', 'https://gateway.forumindex.com'],
libp2pJsClientsOptions: undefined,
pkcRpcClientsOptions: undefined,
});
});
});
+64 -3
View File
@@ -1,24 +1,85 @@
export const PURE_P2P_BROWSER_SETTING_KEY = '5chan:pure-p2p-browser-enabled';
export const P2P_BROWSER_PKC_OPTIONS = {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
ipfsGatewayUrls: undefined,
kuboRpcClientsOptions: undefined,
pubsubHttpClientsOptions: undefined,
pubsubKuboRpcClientsOptions: undefined,
httpRoutersOptions: ['https://peers.pleb.bot', 'https://peers.forumindex.com'],
};
const GATEWAY_BROWSER_PKC_OPTIONS = {
ipfsGatewayUrls: ['https://ipfsgateway.xyz', 'https://gateway.plebpubsub.xyz', 'https://gateway.forumindex.com'],
kuboRpcClientsOptions: undefined,
libp2pJsClientsOptions: undefined,
pubsubHttpClientsOptions: undefined,
pubsubKuboRpcClientsOptions: ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0'],
httpRoutersOptions: ['https://routing.lol', 'https://peers.pleb.bot', 'https://peers.plebpubsub.xyz', 'https://peers.forumindex.com'],
};
type P2PBrowserConfigWindow = {
location: Pick<Location, 'hostname'>;
defaultPkcOptions?: Record<string, unknown>;
electronApi?: { isElectron?: boolean };
isElectron?: boolean;
localStorage?: Pick<Storage, 'getItem' | 'setItem'>;
};
export const isP2PBrowserHostname = (hostname: string) => hostname.toLowerCase().startsWith('p2p.');
export const getBrowserPureP2PPkcOptions = () => ({
...P2P_BROWSER_PKC_OPTIONS,
libp2pJsClientsOptions: P2P_BROWSER_PKC_OPTIONS.libp2pJsClientsOptions.map((options) => ({ ...options })),
httpRoutersOptions: [...P2P_BROWSER_PKC_OPTIONS.httpRoutersOptions],
});
export const getBrowserGatewayPkcOptions = () => ({
...GATEWAY_BROWSER_PKC_OPTIONS,
ipfsGatewayUrls: [...GATEWAY_BROWSER_PKC_OPTIONS.ipfsGatewayUrls],
pubsubKuboRpcClientsOptions: [...GATEWAY_BROWSER_PKC_OPTIONS.pubsubKuboRpcClientsOptions],
httpRoutersOptions: [...GATEWAY_BROWSER_PKC_OPTIONS.httpRoutersOptions],
});
export const getPureP2PBrowserPreference = (targetWindow: P2PBrowserConfigWindow = window) => {
try {
const storedValue = targetWindow.localStorage?.getItem(PURE_P2P_BROWSER_SETTING_KEY);
if (storedValue === 'true') return true;
if (storedValue === 'false') return false;
} catch {
return undefined;
}
return undefined;
};
export const setPureP2PBrowserPreference = (enabled: boolean, targetWindow: P2PBrowserConfigWindow = window) => {
try {
targetWindow.localStorage?.setItem(PURE_P2P_BROWSER_SETTING_KEY, String(enabled));
} catch {
return;
}
};
export const isElectronRuntime = (targetWindow: P2PBrowserConfigWindow = window) => targetWindow.electronApi?.isElectron === true || targetWindow.isElectron === true;
export const shouldUsePureP2PBrowser = (targetWindow: P2PBrowserConfigWindow = window) => {
if (isElectronRuntime(targetWindow)) return false;
const preference = getPureP2PBrowserPreference(targetWindow);
if (preference !== undefined) return preference;
return false;
};
export const configureP2PBrowserPkcOptions = (targetWindow: P2PBrowserConfigWindow = window) => {
if (!isP2PBrowserHostname(targetWindow.location.hostname)) {
if (!shouldUsePureP2PBrowser(targetWindow)) {
return false;
}
targetWindow.defaultPkcOptions = {
...targetWindow.defaultPkcOptions,
libp2pJsClientsOptions: P2P_BROWSER_PKC_OPTIONS.libp2pJsClientsOptions.map((options) => ({ ...options })),
httpRoutersOptions: [...P2P_BROWSER_PKC_OPTIONS.httpRoutersOptions],
...getBrowserPureP2PPkcOptions(),
};
return true;
+72
View File
@@ -0,0 +1,72 @@
import { getBrowserGatewayPkcOptions, getBrowserPureP2PPkcOptions, isElectronRuntime, shouldUsePureP2PBrowser } from './p2p-browser-config';
export const P2P_STATS_SECTION_ID = 'p2p-stats-settings';
export type P2PRuntimeMode = 'browser-libp2p' | 'electron-kubo-rpc';
type AccountProtocolOptions = {
httpRoutersOptions?: string[];
ipfsGatewayUrls?: string[];
kuboRpcClientsOptions?: unknown[];
libp2pJsClientsOptions?: unknown[];
pkcRpcClientsOptions?: string[];
pubsubHttpClientsOptions?: unknown[];
pubsubKuboRpcClientsOptions?: unknown[];
};
type AccountShape = {
pkc?: {
clients?: {
libp2pJsClients?: Record<string, unknown>;
pkcRpcClients?: Record<string, unknown>;
};
};
pkcOptions?: AccountProtocolOptions;
};
const toAccountShape = (account: unknown) => account as AccountShape | undefined;
const hasArrayItems = (value: unknown) => Array.isArray(value) && value.length > 0;
const hasObjectItems = (value: unknown) => !!value && typeof value === 'object' && Object.keys(value).length > 0;
export const getP2PRuntimeMode = (account?: unknown, targetWindow: Window = window): P2PRuntimeMode | null => {
const accountShape = toAccountShape(account);
const protocolOptions = accountShape?.pkcOptions;
const clients = accountShape?.pkc?.clients;
if (hasArrayItems(protocolOptions?.libp2pJsClientsOptions) || hasObjectItems(clients?.libp2pJsClients)) {
return 'browser-libp2p';
}
if (isElectronRuntime(targetWindow) && (hasArrayItems(protocolOptions?.pkcRpcClientsOptions) || hasObjectItems(clients?.pkcRpcClients))) {
return 'electron-kubo-rpc';
}
return null;
};
export const canConfigureBrowserPureP2P = (targetWindow: Window = window) => !isElectronRuntime(targetWindow);
export const shouldShowP2PSettingsSection = (account?: unknown, targetWindow: Window = window) =>
getP2PRuntimeMode(account, targetWindow) !== null || (canConfigureBrowserPureP2P(targetWindow) && isBrowserPureP2PEnabled(account, targetWindow));
export const isBrowserPureP2PEnabled = (account?: unknown, targetWindow: Window = window) => {
const accountShape = toAccountShape(account);
if (!canConfigureBrowserPureP2P(targetWindow)) return false;
if (getP2PRuntimeMode(account, targetWindow) === 'browser-libp2p') return true;
if (hasArrayItems(accountShape?.pkcOptions?.ipfsGatewayUrls) || hasArrayItems(accountShape?.pkcOptions?.pubsubKuboRpcClientsOptions)) return false;
return shouldUsePureP2PBrowser(targetWindow);
};
export const getBrowserPureP2PAccountOptions = (account?: unknown) => ({
...toAccountShape(account)?.pkcOptions,
...getBrowserPureP2PPkcOptions(),
pkcRpcClientsOptions: undefined,
});
export const getBrowserGatewayAccountOptions = (account?: unknown) => ({
...toAccountShape(account)?.pkcOptions,
...getBrowserGatewayPkcOptions(),
pkcRpcClientsOptions: undefined,
});
+4 -9
View File
@@ -592,18 +592,13 @@ const FAQ_SECTIONS: FAQSection[] = [
question: 'Can browsers connect peer-to-peer?',
answer: (
<>
Yes, experimentally. The standard web app can use HTTP gateways, routers, and pubsub providers as compatibility helpers. The{' '}
<a href='https://p2p.5chan.app' {...externalLinkProps}>
p2p.5chan.app
</a>{' '}
subdomain instead runs a{' '}
Yes. The web app can run a{' '}
<a href='https://helia.io' {...externalLinkProps}>
Helia
</a>{' '}
IPFS node in the browser, so it can load boards peer-to-peer without centralized IPFS RPC gateways. Browser nodes still have restrictions, such as limited
inbound connectivity, so they can load data peer-to-peer but are not a stable way to host board data yet. Helia pubsub is currently unstable for 5chan's
needs, which is why the pure browser P2P site is experimental. The desktop apps are the stable pure-P2P option today because they run a full Bitsocial node
with IPFS Kubo.
IPFS node in the browser, so it can load boards peer-to-peer without centralized IPFS RPC gateways. You can turn pure browser P2P on in advanced settings and
inspect the connection in <Link to='/all/settings#p2p-stats-settings'>P2P stats</Link>. Browser nodes still have restrictions, such as limited inbound
connectivity, so desktop apps remain the best way to host board data because they run a full Bitsocial node with IPFS Kubo.
</>
),
},