fix(pubsub): repair browser pure p2p publishing

This commit is contained in:
Tommaso Casaburi
2026-06-18 14:18:14 +07:00
parent de06d5d4bc
commit 8cf03a7bb4
10 changed files with 270 additions and 5 deletions
@@ -1,5 +1,5 @@
diff --git a/dist/browser/helia/helia-for-pkc.js b/dist/browser/helia/helia-for-pkc.js
index e6821667b0601ac56a850e989bfedf76c14796a2..1a2ae7ab9b0afc605c9b4dcddccf25ac02aa1337 100644
index e6821667b0601ac56a850e989bfedf76c14796a2..87d84a502bdbf72b8cf22b7a8ac6bd68e542c611 100644
--- a/dist/browser/helia/helia-for-pkc.js
+++ b/dist/browser/helia/helia-for-pkc.js
@@ -145,6 +145,11 @@ export async function createLibp2pJsClientOrUseExistingOne(pkcOptions) {
@@ -45,7 +45,7 @@ index e6821667b0601ac56a850e989bfedf76c14796a2..1a2ae7ab9b0afc605c9b4dcddccf25ac
unsubscribe: async (topic, handler, options) => {
throwIfHeliaIsStoppingOrStopped();
diff --git a/dist/browser/publications/publication.js b/dist/browser/publications/publication.js
index 5e17c02a39c2715c7ac932695fe90d5078bce405..2d43d996e2b3cdab759e89c296eb9ed8aae857db 100644
index 5e17c02a39c2715c7ac932695fe90d5078bce405..8c61955ee6ef6dd353e57584b4aaf745e143f6d4 100644
--- a/dist/browser/publications/publication.js
+++ b/dist/browser/publications/publication.js
@@ -842,8 +842,14 @@ class Publication extends TypedEmitter {
+7
View File
@@ -30,7 +30,9 @@ const testState = vi.hoisted(() => ({
isMobile: false,
isSpecialEnabled: false,
shouldShowSnow: true,
createAccountMock: vi.fn().mockResolvedValue(undefined),
removeSnowMock: vi.fn(),
setAccountMock: vi.fn().mockResolvedValue(undefined),
replyModalState: {
activeCid: null,
closeModal: vi.fn(),
@@ -49,7 +51,10 @@ const testState = vi.hoisted(() => ({
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
createAccount: () => testState.createAccountMock(),
setAccount: (account: unknown) => testState.setAccountMock(account),
useAccount: () => testState.account,
useAccounts: () => ({ accounts: testState.account ? [testState.account] : [] }),
useAccountComment: ({ commentIndex }: { commentIndex?: number }) => (typeof commentIndex === 'number' ? testState.accountComments[commentIndex] : undefined),
useCommunity: (options?: { communityAddress?: string; community?: { name?: string; publicKey?: string } }) => {
const communityAddress = options?.communityAddress ?? options?.community?.name ?? options?.community?.publicKey;
@@ -365,6 +370,8 @@ describe('App', () => {
testState.isDirectoryCandidate = false;
testState.communities = {};
testState.useThemeMock.mockReset();
testState.createAccountMock.mockReset().mockResolvedValue(undefined);
testState.setAccountMock.mockReset().mockResolvedValue(undefined);
testState.closeCreateBoardModalMock.mockReset();
testState.initSnowMock.mockReset();
testState.removeSnowMock.mockReset();
+3
View File
@@ -13,6 +13,7 @@ import useIsMobile from './hooks/use-is-mobile';
import { useAccountCommunityAddresses } from './hooks/use-account-community-addresses';
import useTheme from './hooks/use-theme';
import { useDirectories } from './hooks/use-directories';
import { useBrowserPureP2PAccountUpgrade } from './hooks/use-browser-pure-p2p-account-upgrade';
import { useCommunityIdentifier } from './hooks/use-community-identifiers';
import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from './hooks/use-resolved-community-address';
import useSafeAccountComment from './hooks/use-safe-account-comment';
@@ -281,6 +282,8 @@ const ModQueueRoute = () => {
};
const App = () => {
useBrowserPureP2PAccountUpgrade();
// Feed routes are always rendered by FeedCacheContainer (Virtuoso used for all modes)
const boardFeedElement = null;
const catalogFeedElement = null;
+1
View File
@@ -1,5 +1,6 @@
declare global {
interface Window {
BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING?: boolean;
isElectron: boolean;
defaultPkcOptions?: Record<string, unknown>;
}
@@ -0,0 +1,164 @@
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: undefined as Record<string, any> | undefined,
accounts: [] as Record<string, any>[] | undefined,
createAccountMock: vi.fn().mockResolvedValue(undefined),
setAccountMock: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
createAccount: () => testState.createAccountMock(),
setAccount: (account: unknown) => testState.setAccountMock(account),
useAccount: () => testState.account,
useAccounts: () => ({ accounts: testState.accounts }),
}));
let container: HTMLDivElement;
const originalLocation = window.location;
let reloadMock: ReturnType<typeof vi.fn>;
let root: Root;
const loadHook = async () => (await import('../use-browser-pure-p2p-account-upgrade')).useBrowserPureP2PAccountUpgrade;
const TestComponent = ({ useUpgrade }: { useUpgrade: () => void }) => {
useUpgrade();
return null;
};
const renderHook = async () => {
const useUpgrade = await loadHook();
await act(async () => {
root.render(createElement(TestComponent, { useUpgrade }));
await Promise.resolve();
await Promise.resolve();
});
};
describe('useBrowserPureP2PAccountUpgrade', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
localStorage.clear();
testState.account = undefined;
testState.accounts = [];
testState.createAccountMock.mockReset().mockResolvedValue(undefined);
testState.setAccountMock.mockReset().mockResolvedValue(undefined);
reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
hostname: '5chan.app',
reload: reloadMock,
},
});
window.electronApi = undefined;
window.isElectron = false;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
vi.useRealTimers();
if (root) {
act(() => root.unmount());
}
container?.remove();
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = undefined;
window.electronApi = undefined;
window.isElectron = false;
});
it('upgrades stale gateway browser accounts and reloads after saving', async () => {
testState.account = {
id: 'account-1',
name: 'Account 1',
pkcOptions: {
httpRoutersOptions: ['https://router.old.example'],
ipfsGatewayUrls: ['https://gateway.old.example'],
pubsubKuboRpcClientsOptions: ['https://pubsub.old.example/api/v0'],
},
};
testState.accounts = [testState.account];
await renderHook();
expect(testState.setAccountMock).toHaveBeenCalledWith(
expect.objectContaining({
id: 'account-1',
pkcOptions: expect.objectContaining({
ipfsGatewayUrls: undefined,
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
pkcRpcClientsOptions: undefined,
pubsubKuboRpcClientsOptions: undefined,
}),
}),
);
expect(reloadMock).toHaveBeenCalledOnce();
});
it('does not upgrade browser full-node accounts', async () => {
testState.account = {
id: 'account-1',
name: 'Account 1',
pkcOptions: {
pkcRpcClientsOptions: ['ws://node.example/key'],
},
};
testState.accounts = [testState.account];
await renderHook();
expect(testState.setAccountMock).not.toHaveBeenCalled();
expect(reloadMock).not.toHaveBeenCalled();
});
it('recovers a missing browser account after the hooks store finishes initializing', async () => {
vi.useFakeTimers();
window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = true;
await renderHook();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(testState.createAccountMock).not.toHaveBeenCalled();
window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = false;
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
await Promise.resolve();
});
expect(testState.createAccountMock).toHaveBeenCalledOnce();
expect(reloadMock).toHaveBeenCalledOnce();
});
it('treats temporarily missing accounts as empty while recovering', async () => {
vi.useFakeTimers();
testState.accounts = undefined;
window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING = false;
await renderHook();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
await Promise.resolve();
});
expect(testState.createAccountMock).toHaveBeenCalledOnce();
expect(reloadMock).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react';
import { createAccount, setAccount, useAccount, useAccounts } from '@bitsocial/bitsocial-react-hooks';
import { getBrowserPureP2PAccountOptions, shouldUpgradeBrowserPureP2PAccount } from '../lib/p2p-runtime';
type AccountShape = Record<string, unknown> & {
id?: string;
};
const ACCOUNT_RECOVERY_CHECK_MS = 1000;
export const useBrowserPureP2PAccountUpgrade = () => {
const account = useAccount() as AccountShape | undefined;
const { accounts = [] } = useAccounts();
const recoveryStartedRef = useRef(false);
const upgradeAccountIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (account?.id || accounts.length > 0 || recoveryStartedRef.current) return;
const intervalId = window.setInterval(() => {
if (window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZING) return;
recoveryStartedRef.current = true;
window.clearInterval(intervalId);
void createAccount()
.then(() => {
window.location.reload();
})
.catch((error) => {
recoveryStartedRef.current = false;
console.error('Failed to recover missing browser account', error);
});
}, ACCOUNT_RECOVERY_CHECK_MS);
return () => {
window.clearInterval(intervalId);
};
}, [account?.id, accounts.length]);
useEffect(() => {
if (!account?.id || !shouldUpgradeBrowserPureP2PAccount(account)) return;
if (upgradeAccountIdRef.current === account.id) return;
upgradeAccountIdRef.current = account.id;
void setAccount({
...account,
pkcOptions: getBrowserPureP2PAccountOptions(account),
})
.then(() => {
window.location.reload();
})
.catch((error) => {
upgradeAccountIdRef.current = undefined;
console.error('Failed to upgrade browser account to pure P2P options', error);
});
}, [account]);
};
+16
View File
@@ -5,6 +5,7 @@ import {
getP2PRuntimeMode,
isBrowserPureP2PEnabled,
shouldShowP2PSettingsSection,
shouldUpgradeBrowserPureP2PAccount,
} from '../p2p-runtime';
const browserWindow = {
@@ -91,6 +92,20 @@ describe('p2p-runtime', () => {
expect(shouldShowP2PSettingsSection(account, browserWindowWithDisabledPureP2P)).toBe(true);
});
it('upgrades only stale gateway browser accounts when pure p2p is enabled', () => {
const gatewayAccount = { pkcOptions: { ipfsGatewayUrls: ['https://gateway.example'] } };
const browserAccount = { pkcOptions: { libp2pJsClientsOptions: [{ key: 'libp2pjs' }] } };
const mixedBrowserAccount = { pkcOptions: { libp2pJsClientsOptions: [{ key: 'libp2pjs' }], pubsubKuboRpcClientsOptions: ['https://pubsub.example/api/v0'] } };
const fullNodeAccount = { pkcOptions: { pkcRpcClientsOptions: ['ws://node.example'] } };
expect(shouldUpgradeBrowserPureP2PAccount(gatewayAccount, browserWindow)).toBe(true);
expect(shouldUpgradeBrowserPureP2PAccount(browserAccount, browserWindow)).toBe(false);
expect(shouldUpgradeBrowserPureP2PAccount(mixedBrowserAccount, browserWindow)).toBe(true);
expect(shouldUpgradeBrowserPureP2PAccount(fullNodeAccount, browserWindow)).toBe(false);
expect(shouldUpgradeBrowserPureP2PAccount(gatewayAccount, browserWindowWithDisabledPureP2P)).toBe(false);
expect(shouldUpgradeBrowserPureP2PAccount(gatewayAccount, electronWindow)).toBe(false);
});
it('builds browser p2p and gateway account options without a direct pkc-js import', () => {
const account = {
pkcOptions: {
@@ -104,6 +119,7 @@ describe('p2p-runtime', () => {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
ipfsGatewayUrls: undefined,
pkcRpcClientsOptions: undefined,
pubsubKuboRpcClientsOptions: undefined,
});
expect(getBrowserGatewayAccountOptions(account)).toMatchObject({
httpRoutersOptions: ['https://custom-router.example'],
+7 -2
View File
@@ -1,12 +1,14 @@
export const PURE_P2P_BROWSER_SETTING_KEY = '5chan:pure-p2p-browser-enabled';
export const BROWSER_PURE_P2P_DEFAULT_ENABLED = true;
const BROWSER_PUBSUB_KUBO_RPC_CLIENTS_OPTIONS = ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0'];
export const P2P_BROWSER_PKC_OPTIONS = {
libp2pJsClientsOptions: [{ key: 'libp2pjs' }],
ipfsGatewayUrls: undefined,
kuboRpcClientsOptions: undefined,
pubsubHttpClientsOptions: undefined,
pubsubKuboRpcClientsOptions: undefined,
pubsubKuboRpcClientsOptions: undefined as string[] | undefined,
httpRoutersOptions: ['https://peers.plebpubsub.xyz', 'https://routing.lol', 'https://peers.pleb.bot'],
};
@@ -15,7 +17,7 @@ const GATEWAY_BROWSER_PKC_OPTIONS = {
kuboRpcClientsOptions: undefined,
libp2pJsClientsOptions: undefined,
pubsubHttpClientsOptions: undefined,
pubsubKuboRpcClientsOptions: ['https://pubsubprovider.xyz/api/v0', 'https://plebpubsub.xyz/api/v0', 'https://rannithepleb.com/api/v0'],
pubsubKuboRpcClientsOptions: BROWSER_PUBSUB_KUBO_RPC_CLIENTS_OPTIONS,
httpRoutersOptions: ['https://routing.lol', 'https://peers.pleb.bot', 'https://peers.plebpubsub.xyz', 'https://peers.forumindex.com'],
};
@@ -27,9 +29,12 @@ type P2PBrowserConfigWindow = {
localStorage?: Pick<Storage, 'getItem' | 'setItem'>;
};
const cloneArray = <T>(value: T[] | undefined) => (value ? [...value] : undefined);
export const getBrowserPureP2PPkcOptions = () => ({
...P2P_BROWSER_PKC_OPTIONS,
libp2pJsClientsOptions: P2P_BROWSER_PKC_OPTIONS.libp2pJsClientsOptions.map((options) => ({ ...options })),
pubsubKuboRpcClientsOptions: cloneArray(P2P_BROWSER_PKC_OPTIONS.pubsubKuboRpcClientsOptions),
httpRoutersOptions: [...P2P_BROWSER_PKC_OPTIONS.httpRoutersOptions],
});
+11
View File
@@ -30,6 +30,10 @@ const hasArrayItems = (value: unknown) => Array.isArray(value) && value.length >
const hasObjectItems = (value: unknown) => !!value && typeof value === 'object' && Object.keys(value).length > 0;
const hasMixedBrowserPureP2POptions = (protocolOptions: AccountProtocolOptions | undefined) =>
hasArrayItems(protocolOptions?.libp2pJsClientsOptions) &&
(hasArrayItems(protocolOptions?.kuboRpcClientsOptions) || hasArrayItems(protocolOptions?.pubsubKuboRpcClientsOptions));
export const getP2PRuntimeMode = (account?: unknown, targetWindow: Window = window): P2PRuntimeMode | null => {
const accountShape = toAccountShape(account);
const protocolOptions = accountShape?.pkcOptions;
@@ -66,6 +70,13 @@ export const getBrowserPureP2PAccountOptions = (account?: unknown) => ({
pkcRpcClientsOptions: undefined,
});
export const shouldUpgradeBrowserPureP2PAccount = (account?: unknown, targetWindow: Window = window) => {
if (!account || typeof account !== 'object' || !canConfigureBrowserPureP2P(targetWindow) || !isBrowserPureP2PEnabled(account, targetWindow)) return false;
const protocolOptions = toAccountShape(account)?.pkcOptions;
return getP2PRuntimeMode(account, targetWindow) === null || hasMixedBrowserPureP2POptions(protocolOptions);
};
export const getBrowserGatewayAccountOptions = (account?: unknown) => {
const protocolOptions = toAccountShape(account)?.pkcOptions;
const gatewayOptions = getBrowserGatewayPkcOptions();
+1 -1
View File
@@ -6272,7 +6272,7 @@ __metadata:
"@pkcprotocol/pkc-js@patch:@pkcprotocol/pkc-js@npm%3A0.0.47#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch":
version: 0.0.47
resolution: "@pkcprotocol/pkc-js@patch:@pkcprotocol/pkc-js@npm%3A0.0.47#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch::version=0.0.47&hash=df4e0a"
resolution: "@pkcprotocol/pkc-js@patch:@pkcprotocol/pkc-js@npm%3A0.0.47#~/.yarn/patches/@pkcprotocol-pkc-js-npm-0.0.47-e2131d72c8.patch::version=0.0.47&hash=bbfad6"
dependencies:
"@enhances/with-resolvers": "npm:0.0.5"
"@helia/block-brokers": "npm:5.2.4"