Files
libp2p-native-bridge/implementations/Libp2pComponent.ts
Chris Daßler 9122949a6e Merge feat/logger (#1)
Fixed encoding issue: changed from btoa(String.fromCharCode(...)) to Buffer.from().toString('base64')
Maintains persistent identity through private key storage
Provides better state management and error tracking
Supports connection acceptance/rejection flow
Properly handles node lifecycle events
Can be cleanly shut down with resource cleanup

Co-authored-by: Chris Daßler <chris.dassler@me.com>
Reviewed-on: #1
2025-09-18 15:25:17 +02:00

516 lines
16 KiB
TypeScript

/**
* Native libp2p bridge component for React Native
*/
import { NativeEventEmitter, NativeModules } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { LIBP2P_CONFIG } from '../utils/constants';
import type { ConnectionStatusEvent, PeerDiscoveredEvent, PeerInfoEvent } from '../utils/types';
import type {
Connection,
ILibp2pComponent,
Libp2pEvents,
Libp2pOptions,
Multiaddr,
PeerId,
PeerInfo,
} from '../interfaces/ILibp2pComponent';
// Helper class to create PeerId-like objects from strings
export class SimplePeerId implements PeerId {
constructor(private id: string) {}
toString(): string {
return this.id;
}
toBytes(): Uint8Array {
return new TextEncoder().encode(this.id);
}
equals(other: PeerId): boolean {
return this.toString() === other.toString();
}
}
// Helper class to create Multiaddr-like objects from strings
export class SimpleMultiaddr implements Multiaddr {
bytes: Uint8Array;
constructor(private addr: string) {
this.bytes = new TextEncoder().encode(addr);
}
toString(): string {
return this.addr;
}
protos(): Array<{ code: number; name: string }> {
// Simple parsing of multiaddr components
const parts = this.addr.split('/').filter((p) => p);
const protos = [];
for (let i = 0; i < parts.length; i += 2) {
const name = parts[i];
protos.push({ code: 0, name }); // Simplified - real implementation would have proper codes
}
return protos;
}
getPeerId(): string | null {
const match = this.addr.match(/\/p2p\/([^/]+)/);
return match ? match[1] : null;
}
}
// Type for event handler functions
type EventHandler<T = unknown> = (evt: T) => void;
// Native implementation that wraps our iOS/Android modules
export class Libp2pComponent implements ILibp2pComponent {
private nativeModule: any;
private eventEmitter: NativeEventEmitter;
private eventHandlers: Map<string, Set<EventHandler>> = new Map();
private _peerId?: PeerId;
private _multiaddrs: Multiaddr[] = [];
private _started: boolean = false;
private _starting: boolean = false;
private _error: string | null = null;
private cachedConnections: Connection[] = [];
private options: Libp2pOptions;
private nodeStartedListener: any = null;
private nodeStoppedListener: any = null;
private errorListener: any = null;
constructor(options?: Libp2pOptions, nativeModules?: typeof NativeModules) {
// Allow dependency injection of NativeModules for testing
const modules = nativeModules || NativeModules;
this.nativeModule = modules.Libp2pModule;
this.eventEmitter = new NativeEventEmitter(this.nativeModule);
this.options = options || {};
if (!this.nativeModule) {
throw new Error('Libp2p native module not found');
}
this.setupNativeEventListeners();
this.setupProtocolHandlers();
this.setupStateListeners();
}
get peerId(): PeerId | null {
return this._peerId || null;
}
get multiaddrs(): Multiaddr[] {
return this._multiaddrs;
}
get isStarting(): boolean {
return this._starting;
}
get error(): string | null {
return this._error;
}
private setupNativeEventListeners(): void {
// Map native events to js-libp2p style events
// Peer info update
this.eventEmitter.addListener('onPeerInfo', ({ peerId, multiaddrs }: PeerInfoEvent) => {
this._peerId = new SimplePeerId(peerId);
this._multiaddrs = multiaddrs.map((addr: string) => new SimpleMultiaddr(addr));
this.emit('self:peer:update', {
peerId: this._peerId,
multiaddrs: this._multiaddrs,
});
});
// Peer discovery
this.eventEmitter.addListener(
'onPeerDiscovered',
({ peerId, addresses, multiaddrs }: PeerDiscoveredEvent) => {
const addrs = (multiaddrs || addresses || []).map(
(addr: string) => new SimpleMultiaddr(addr),
);
this.emit('peer:discovery', {
id: new SimplePeerId(peerId),
multiaddrs: addrs,
});
},
);
// Peer lost (for mDNS service lost events)
this.eventEmitter.addListener('onPeerLost', ({ peerId }: { peerId: string }) => {
this.emit('peer:lost', {
id: new SimplePeerId(peerId),
});
});
// Connection events
this.eventEmitter.addListener(
'onConnectionStatus',
({ peerId, status, direction, multiaddr }: ConnectionStatusEvent) => {
const connection: Connection = {
id: `${peerId}-${Date.now()}`,
remotePeer: new SimplePeerId(peerId),
remoteAddr: new SimpleMultiaddr(multiaddr || ''),
stat: {
direction: direction || 'outbound',
status:
status === LIBP2P_CONFIG.CONNECTION_STATUS.CONNECTED
? 'open'
: status === LIBP2P_CONFIG.CONNECTION_STATUS.PENDING
? 'pending'
: status === LIBP2P_CONFIG.CONNECTION_STATUS.DISCONNECTED
? 'closed'
: 'closing',
timeline: {
open: Date.now(),
},
},
};
if (
status === LIBP2P_CONFIG.CONNECTION_STATUS.CONNECTED ||
status === LIBP2P_CONFIG.CONNECTION_STATUS.PENDING
) {
// Emit peer:connect for both pending and connected states
// The UI will differentiate based on connection.stat.status
this.emit('peer:connect', connection);
this.emit('connection:open', connection);
} else if (
status === LIBP2P_CONFIG.CONNECTION_STATUS.DISCONNECTED ||
status === LIBP2P_CONFIG.CONNECTION_STATUS.FAILED
) {
connection.stat.timeline.close = Date.now();
connection.stat.status = 'closed';
this.emit('peer:disconnect', connection);
this.emit('connection:close', connection);
}
},
);
}
private setupProtocolHandlers(): void {
// Listen for protocol data events
this.eventEmitter.addListener(
'onProtocolData',
(data: { protocolId: string; peerId: string; data?: number[] }) => {
// Find matching protocol handler
const handler = this.options.protocols?.find((p) => p.protocolId === data.protocolId);
if (handler) {
// Convert number array to Uint8Array if needed
const uint8Data = data.data ? new Uint8Array(data.data) : undefined;
handler.handler({ peerId: data.peerId, data: uint8Data });
}
},
);
}
private setupStateListeners(): void {
// Listen for node started event
this.nodeStartedListener = this.eventEmitter.addListener('onNodeStarted', () => {
this._started = true;
this._starting = false;
this._error = null;
});
// Listen for node stopped event
this.nodeStoppedListener = this.eventEmitter.addListener('onNodeStopped', () => {
this._started = false;
this._starting = false;
});
// Listen for error events
this.errorListener = this.eventEmitter.addListener('onError', (event: any) => {
this._error = event.error || 'Unknown error';
this._starting = false;
});
}
private emit<K extends keyof Libp2pEvents>(event: K, detail: unknown): void {
const handlers = this.eventHandlers.get(event);
if (handlers) {
const customEvent = new CustomEvent(event, { detail });
for (const handler of handlers) {
handler(customEvent as Libp2pEvents[K]);
}
}
}
async start(): Promise<void> {
if (this._started || this._starting) {
return; // Already started or starting
}
this._starting = true;
this._error = null;
try {
// Register protocols if any
if (this.options.protocols && this.nativeModule.registerProtocolHandler) {
for (const protocol of this.options.protocols) {
await this.nativeModule.registerProtocolHandler(protocol.protocolId);
}
}
// Load stored private key if available and not provided
let privateKeyBytes = this.options.keypair?.privateKey;
if (!privateKeyBytes) {
try {
const storedKey = await AsyncStorage.getItem('libp2p_private_key');
if (storedKey) {
privateKeyBytes = new Uint8Array(JSON.parse(storedKey));
}
} catch (err) {
// Failed to load stored key, will generate new one
}
}
// Pass configuration options to native module including keypair
const config: any = {
tcpPort: this.options.config?.tcpPort,
wsPort: this.options.config?.wsPort,
};
if (privateKeyBytes) {
// Convert Uint8Array to base64 for passing to native module
config.privateKey = Buffer.from(privateKeyBytes).toString('base64');
}
const result = await this.nativeModule.startLibp2p(config);
// Update internal state from result
if (result.peerId) {
this._peerId = new SimplePeerId(result.peerId);
}
if (result.multiaddrs) {
this._multiaddrs = result.multiaddrs.map((addr: string) => new SimpleMultiaddr(addr));
}
// Store the private key if we generated a new one
if (result.privateKey && !privateKeyBytes) {
try {
const keyBytes = Buffer.from(result.privateKey, 'base64');
await AsyncStorage.setItem('libp2p_private_key', JSON.stringify(Array.from(keyBytes)));
} catch (err) {
// Failed to store key, but continue
}
}
this._started = true;
this._starting = false;
} catch (error) {
this._starting = false;
this._error = error instanceof Error ? error.message : 'Failed to start';
throw error;
}
}
async stop(): Promise<void> {
try {
await this.nativeModule.stopLibp2p();
this._started = false;
this._peerId = undefined;
this._multiaddrs = [];
} catch (error) {
// If libp2p wasn't running, that's okay - just update our state
const errorMessage = error instanceof Error ? error.message : String(error);
const errorCode = (error as { code?: string })?.code;
if (errorMessage.includes('not running') || errorCode === 'LIBP2P_ERROR') {
this._started = false;
} else {
// Re-throw other errors
throw error;
}
}
}
cleanup(): void {
// Remove state listeners
if (this.nodeStartedListener) {
this.nodeStartedListener.remove();
this.nodeStartedListener = null;
}
if (this.nodeStoppedListener) {
this.nodeStoppedListener.remove();
this.nodeStoppedListener = null;
}
if (this.errorListener) {
this.errorListener.remove();
this.errorListener = null;
}
// Clear event handlers
this.eventHandlers.clear();
}
async dial(multiaddr: string): Promise<Connection> {
await this.nativeModule.connectToPeer(multiaddr);
// Create connection object
const peerId = multiaddr.match(/\/p2p\/([^/]+)/)?.[1] || '';
return {
id: `${peerId}-${Date.now()}`,
remotePeer: new SimplePeerId(peerId),
remoteAddr: new SimpleMultiaddr(multiaddr),
stat: {
direction: 'outbound',
status: 'open',
timeline: {
open: Date.now(),
},
},
};
}
async hangUp(peerId: string): Promise<void> {
const result = await this.nativeModule.hangUp(peerId);
if (!result?.success) {
// No active connections to peer - this is okay
}
}
async getConnections(peerId?: string): Promise<Connection[]> {
try {
const rawConnections = await this.nativeModule.getConnections();
// Transform native connections to match the Connection interface
this.cachedConnections = (rawConnections || []).map((conn: any) => {
// Ensure the connection has the expected structure
// Handle different formats from native modules
let peerIdStr = conn.peerId || 'unknown';
if (conn.remotePeer) {
// If remotePeer is an object with toString property (from native module)
if (typeof conn.remotePeer.toString === 'string') {
peerIdStr = conn.remotePeer.toString;
}
// If remotePeer is a PeerId object with toString method
else if (typeof conn.remotePeer.toString === 'function') {
peerIdStr = conn.remotePeer.toString();
}
}
const connection: Connection = {
id: conn.id || `${peerIdStr}-${Date.now()}`,
remotePeer: new SimplePeerId(peerIdStr),
remoteAddr: conn.remoteAddr?.toString
? new SimpleMultiaddr(
typeof conn.remoteAddr.toString === 'string'
? conn.remoteAddr.toString
: conn.remoteAddr.toString(),
)
: new SimpleMultiaddr('/unknown'),
stat: conn.stat || {
direction: conn.direction || 'outbound',
status: conn.status || 'open',
timeline: conn.timeline || {
open: Date.now(),
},
},
};
return connection;
});
// Filter by peerId if provided
if (peerId) {
return this.cachedConnections.filter((conn) => conn.remotePeer.toString() === peerId);
}
return this.cachedConnections;
} catch (_error) {
// Failed to get connections
return [];
}
}
addEventListener<K extends keyof Libp2pEvents>(
event: K,
handler: (evt: Libp2pEvents[K]) => void,
): void {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
this.eventHandlers.get(event)?.add(handler as EventHandler);
}
removeEventListener<K extends keyof Libp2pEvents>(
event: K,
handler: (evt: Libp2pEvents[K]) => void,
): void {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.delete(handler as EventHandler);
}
}
async refreshDiscovery(): Promise<void> {
if (this.nativeModule.refreshDiscovery) {
await this.nativeModule.refreshDiscovery();
}
}
async pingPeer(peerId: string): Promise<{ success: boolean; rtt?: number; peerId: string }> {
if (this.nativeModule.pingPeer) {
return await this.nativeModule.pingPeer(peerId);
} else {
throw new Error('Ping not supported on this platform');
}
}
async sendProtocolData(peerId: string, protocolId: string, data: Uint8Array): Promise<void> {
if (this.nativeModule.sendProtocolData) {
// Convert Uint8Array to regular array for native module
const dataArray = Array.from(data);
await this.nativeModule.sendProtocolData(peerId, protocolId, dataArray);
} else {
throw new Error('Protocol sending not supported on this platform');
}
}
async acceptConnection(peerId: string): Promise<void> {
if (!this.nativeModule.acceptConnection) {
throw new Error('acceptConnection not supported by native module');
}
await this.nativeModule.acceptConnection(peerId);
}
async rejectConnection(peerId: string): Promise<void> {
if (!this.nativeModule.rejectConnection) {
throw new Error('rejectConnection not supported by native module');
}
await this.nativeModule.rejectConnection(peerId);
}
}
// Type definition for CustomEventInit
interface CustomEventInit<T = unknown> {
detail?: T;
bubbles?: boolean;
cancelable?: boolean;
}
// Polyfill CustomEvent for React Native
if (typeof CustomEvent === 'undefined') {
const globalObj = global as typeof globalThis & { CustomEvent: typeof CustomEvent };
globalObj.CustomEvent = class CustomEvent<T = unknown> {
readonly type: string;
readonly detail: T;
readonly bubbles: boolean;
readonly cancelable: boolean;
constructor(type: string, eventInitDict?: CustomEventInit<T>) {
this.type = type;
this.detail = eventInitDict?.detail as T;
this.bubbles = eventInitDict?.bubbles || false;
this.cancelable = eventInitDict?.cancelable || false;
}
preventDefault() {}
stopPropagation() {}
stopImmediatePropagation() {}
} as any;
}