@gethashd/bytecave-browser 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +176 -0
- package/README.md +699 -0
- package/dist/__tests__/p2p-protocols.test.d.ts +10 -0
- package/dist/chunk-EEZWRIUI.js +160 -0
- package/dist/chunk-OJEETLZQ.js +7087 -0
- package/dist/client.d.ts +107 -0
- package/dist/contracts/ContentRegistry.d.ts +1 -0
- package/dist/discovery.d.ts +28 -0
- package/dist/index.cjs +7291 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +50 -0
- package/dist/p2p-protocols.d.ts +114 -0
- package/dist/protocol-handler.cjs +185 -0
- package/dist/protocol-handler.d.ts +57 -0
- package/dist/protocol-handler.js +18 -0
- package/dist/provider.d.ts +59 -0
- package/dist/react/components.d.ts +90 -0
- package/dist/react/hooks.d.ts +67 -0
- package/dist/react/index.cjs +6344 -0
- package/dist/react/index.d.ts +8 -0
- package/dist/react/index.js +23 -0
- package/dist/react/useHashdUrl.d.ts +15 -0
- package/dist/types.d.ts +53 -0
- package/package.json +77 -0
- package/src/__tests__/p2p-protocols.test.ts +292 -0
- package/src/client.ts +876 -0
- package/src/contracts/ContentRegistry.ts +6 -0
- package/src/discovery.ts +79 -0
- package/src/index.ts +59 -0
- package/src/p2p-protocols.ts +451 -0
- package/src/protocol-handler.ts +271 -0
- package/src/provider.tsx +275 -0
- package/src/react/components.tsx +177 -0
- package/src/react/hooks.ts +253 -0
- package/src/react/index.ts +9 -0
- package/src/react/useHashdUrl.ts +68 -0
- package/src/types.ts +60 -0
- package/tsconfig.json +19 -0
- package/tsup.config.ts +17 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HashD Protocol Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles hashd:// URLs for loading content from ByteCave network.
|
|
5
|
+
* Format: hashd://{cid}?type={mimeType}&decrypt={boolean}
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ByteCaveClient } from './client.js';
|
|
9
|
+
|
|
10
|
+
export interface HashdUrl {
|
|
11
|
+
protocol: 'hashd:';
|
|
12
|
+
cid: string;
|
|
13
|
+
mimeType?: string;
|
|
14
|
+
decrypt?: boolean;
|
|
15
|
+
raw: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FetchOptions {
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
timeout?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FetchResult {
|
|
24
|
+
data: Uint8Array;
|
|
25
|
+
mimeType: string;
|
|
26
|
+
blobUrl: string;
|
|
27
|
+
cached: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse a hashd:// URL into its components
|
|
32
|
+
*/
|
|
33
|
+
export function parseHashdUrl(url: string): HashdUrl {
|
|
34
|
+
if (!url.startsWith('hashd://')) {
|
|
35
|
+
throw new Error(`Invalid hashd:// URL: ${url}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Remove protocol
|
|
39
|
+
const withoutProtocol = url.slice(8); // Remove 'hashd://'
|
|
40
|
+
|
|
41
|
+
// Split CID and query params
|
|
42
|
+
const [cid, queryString] = withoutProtocol.split('?');
|
|
43
|
+
|
|
44
|
+
if (!cid || cid.length === 0) {
|
|
45
|
+
throw new Error(`Invalid hashd:// URL: missing CID`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result: HashdUrl = {
|
|
49
|
+
protocol: 'hashd:',
|
|
50
|
+
cid,
|
|
51
|
+
raw: url
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Parse query parameters
|
|
55
|
+
if (queryString) {
|
|
56
|
+
const params = new URLSearchParams(queryString);
|
|
57
|
+
|
|
58
|
+
if (params.has('type')) {
|
|
59
|
+
result.mimeType = params.get('type')!;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (params.has('decrypt')) {
|
|
63
|
+
result.decrypt = params.get('decrypt') === 'true';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a hashd:// URL from a CID and options
|
|
72
|
+
*/
|
|
73
|
+
export function createHashdUrl(
|
|
74
|
+
cid: string,
|
|
75
|
+
options?: { mimeType?: string; decrypt?: boolean }
|
|
76
|
+
): string {
|
|
77
|
+
let url = `hashd://${cid}`;
|
|
78
|
+
|
|
79
|
+
if (options) {
|
|
80
|
+
const params = new URLSearchParams();
|
|
81
|
+
|
|
82
|
+
if (options.mimeType) {
|
|
83
|
+
params.set('type', options.mimeType);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.decrypt !== undefined) {
|
|
87
|
+
params.set('decrypt', String(options.decrypt));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const queryString = params.toString();
|
|
91
|
+
if (queryString) {
|
|
92
|
+
url += `?${queryString}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return url;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Blob URL cache to avoid re-fetching content
|
|
101
|
+
*/
|
|
102
|
+
class BlobUrlCache {
|
|
103
|
+
private cache = new Map<string, { blobUrl: string; mimeType: string; timestamp: number }>();
|
|
104
|
+
private readonly maxAge = 60 * 60 * 1000; // 1 hour
|
|
105
|
+
|
|
106
|
+
set(cid: string, blobUrl: string, mimeType: string): void {
|
|
107
|
+
this.cache.set(cid, { blobUrl, mimeType, timestamp: Date.now() });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get(cid: string): { blobUrl: string; mimeType: string } | null {
|
|
111
|
+
const entry = this.cache.get(cid);
|
|
112
|
+
|
|
113
|
+
if (!entry) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check if expired
|
|
118
|
+
if (Date.now() - entry.timestamp > this.maxAge) {
|
|
119
|
+
this.revoke(cid);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { blobUrl: entry.blobUrl, mimeType: entry.mimeType };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
revoke(cid: string): void {
|
|
127
|
+
const entry = this.cache.get(cid);
|
|
128
|
+
if (entry) {
|
|
129
|
+
URL.revokeObjectURL(entry.blobUrl);
|
|
130
|
+
this.cache.delete(cid);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
clear(): void {
|
|
135
|
+
for (const entry of this.cache.values()) {
|
|
136
|
+
URL.revokeObjectURL(entry.blobUrl);
|
|
137
|
+
}
|
|
138
|
+
this.cache.clear();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
size(): number {
|
|
142
|
+
return this.cache.size;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Global cache instance
|
|
147
|
+
const blobCache = new BlobUrlCache();
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Detect MIME type from data if not provided
|
|
151
|
+
*/
|
|
152
|
+
function detectMimeType(data: Uint8Array): string {
|
|
153
|
+
// Check magic bytes for common formats
|
|
154
|
+
if (data.length < 4) {
|
|
155
|
+
return 'application/octet-stream';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// PNG
|
|
159
|
+
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47) {
|
|
160
|
+
return 'image/png';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// JPEG
|
|
164
|
+
if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) {
|
|
165
|
+
return 'image/jpeg';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// GIF
|
|
169
|
+
if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
|
|
170
|
+
return 'image/gif';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// WebP
|
|
174
|
+
if (data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 &&
|
|
175
|
+
data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) {
|
|
176
|
+
return 'image/webp';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// MP4
|
|
180
|
+
if (data.length >= 12 &&
|
|
181
|
+
data[4] === 0x66 && data[5] === 0x74 && data[6] === 0x79 && data[7] === 0x70) {
|
|
182
|
+
return 'video/mp4';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Default
|
|
186
|
+
return 'application/octet-stream';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Fetch content from ByteCave network using hashd:// URL
|
|
191
|
+
*/
|
|
192
|
+
export async function fetchHashdContent(
|
|
193
|
+
url: string | HashdUrl,
|
|
194
|
+
client: ByteCaveClient,
|
|
195
|
+
options?: FetchOptions
|
|
196
|
+
): Promise<FetchResult> {
|
|
197
|
+
// Parse URL if string
|
|
198
|
+
const parsed = typeof url === 'string' ? parseHashdUrl(url) : url;
|
|
199
|
+
|
|
200
|
+
// Check cache first
|
|
201
|
+
const cached = blobCache.get(parsed.cid);
|
|
202
|
+
if (cached) {
|
|
203
|
+
console.log(`[HashD] Cache hit for CID: ${parsed.cid.slice(0, 16)}...`);
|
|
204
|
+
return {
|
|
205
|
+
data: new Uint8Array(), // Don't return data for cached items
|
|
206
|
+
mimeType: cached.mimeType,
|
|
207
|
+
blobUrl: cached.blobUrl,
|
|
208
|
+
cached: true
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(`[HashD] Fetching CID: ${parsed.cid.slice(0, 16)}...`);
|
|
213
|
+
|
|
214
|
+
// Fetch from ByteCave network
|
|
215
|
+
const result = await client.retrieve(parsed.cid);
|
|
216
|
+
|
|
217
|
+
if (!result.success || !result.data) {
|
|
218
|
+
throw new Error(result.error || 'Failed to retrieve content');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Determine MIME type
|
|
222
|
+
const mimeType = parsed.mimeType || detectMimeType(result.data);
|
|
223
|
+
|
|
224
|
+
// Create blob URL (copy data to avoid SharedArrayBuffer issues)
|
|
225
|
+
const dataCopy = new Uint8Array(result.data);
|
|
226
|
+
const blob = new Blob([dataCopy], { type: mimeType });
|
|
227
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
228
|
+
|
|
229
|
+
// Cache the blob URL
|
|
230
|
+
blobCache.set(parsed.cid, blobUrl, mimeType);
|
|
231
|
+
|
|
232
|
+
console.log(`[HashD] Retrieved and cached CID: ${parsed.cid.slice(0, 16)}... (${mimeType})`);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
data: result.data,
|
|
236
|
+
mimeType,
|
|
237
|
+
blobUrl,
|
|
238
|
+
cached: false
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Prefetch content and cache it
|
|
244
|
+
*/
|
|
245
|
+
export async function prefetchHashdContent(
|
|
246
|
+
url: string | HashdUrl,
|
|
247
|
+
client: ByteCaveClient
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
await fetchHashdContent(url, client);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Clear the blob URL cache
|
|
254
|
+
*/
|
|
255
|
+
export function clearHashdCache(): void {
|
|
256
|
+
blobCache.clear();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Get cache statistics
|
|
261
|
+
*/
|
|
262
|
+
export function getHashdCacheStats(): { size: number } {
|
|
263
|
+
return { size: blobCache.size() };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Revoke a specific blob URL from cache
|
|
268
|
+
*/
|
|
269
|
+
export function revokeHashdUrl(cid: string): void {
|
|
270
|
+
blobCache.revoke(cid);
|
|
271
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Context Provider for ByteCave Client
|
|
3
|
+
*
|
|
4
|
+
* Provides P2P connectivity state and methods to React components
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { createContext, useContext, ReactNode, useState, useEffect, useRef, useCallback } from 'react';
|
|
8
|
+
import { ByteCaveClient } from './client.js';
|
|
9
|
+
import type { ByteCaveConfig, PeerInfo, ConnectionState, StoreResult, RetrieveResult } from './types.js';
|
|
10
|
+
|
|
11
|
+
interface NodeHealth {
|
|
12
|
+
status: string;
|
|
13
|
+
blobCount: number;
|
|
14
|
+
storageUsed: number;
|
|
15
|
+
uptime: number;
|
|
16
|
+
nodeId?: string;
|
|
17
|
+
publicKey?: string;
|
|
18
|
+
secp256k1PublicKey?: string;
|
|
19
|
+
ownerAddress?: string;
|
|
20
|
+
metrics?: {
|
|
21
|
+
requestsLastHour: number;
|
|
22
|
+
avgResponseTime: number;
|
|
23
|
+
successRate: number;
|
|
24
|
+
};
|
|
25
|
+
integrity?: {
|
|
26
|
+
checked: number;
|
|
27
|
+
passed: number;
|
|
28
|
+
failed: number;
|
|
29
|
+
orphaned: number;
|
|
30
|
+
metadataTampered: number;
|
|
31
|
+
failedCids: string[];
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ByteCaveContextValue {
|
|
36
|
+
connectionState: ConnectionState;
|
|
37
|
+
peers: PeerInfo[];
|
|
38
|
+
isConnected: boolean;
|
|
39
|
+
appId: string;
|
|
40
|
+
connect: () => Promise<void>;
|
|
41
|
+
disconnect: () => Promise<void>;
|
|
42
|
+
store: (data: Uint8Array, mimeType?: string, signer?: any) => Promise<StoreResult>;
|
|
43
|
+
retrieve: (cid: string) => Promise<RetrieveResult>;
|
|
44
|
+
registerContent: (cid: string, appId: string, signer: any) => Promise<{ success: boolean; txHash?: string; error?: string }>;
|
|
45
|
+
getNodeHealth: (peerId: string) => Promise<NodeHealth | null>;
|
|
46
|
+
error: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ByteCaveContext = createContext<ByteCaveContextValue | null>(null);
|
|
50
|
+
|
|
51
|
+
interface ByteCaveProviderProps {
|
|
52
|
+
children: ReactNode;
|
|
53
|
+
vaultNodeRegistryAddress: string;
|
|
54
|
+
contentRegistryAddress?: string;
|
|
55
|
+
rpcUrl: string;
|
|
56
|
+
appId: string;
|
|
57
|
+
relayPeers?: string[];
|
|
58
|
+
directNodeAddrs?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let globalClient: ByteCaveClient | null = null;
|
|
62
|
+
|
|
63
|
+
export function ByteCaveProvider({
|
|
64
|
+
children,
|
|
65
|
+
vaultNodeRegistryAddress,
|
|
66
|
+
contentRegistryAddress,
|
|
67
|
+
rpcUrl,
|
|
68
|
+
appId,
|
|
69
|
+
relayPeers = [],
|
|
70
|
+
directNodeAddrs = []
|
|
71
|
+
}: ByteCaveProviderProps) {
|
|
72
|
+
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
|
73
|
+
const [peers, setPeers] = useState<PeerInfo[]>([]);
|
|
74
|
+
const [error, setError] = useState<string | null>(null);
|
|
75
|
+
const connectCalledRef = useRef(false);
|
|
76
|
+
|
|
77
|
+
const connect = useCallback(async () => {
|
|
78
|
+
if (!globalClient) {
|
|
79
|
+
setError('ByteCave client not initialized');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const currentState = globalClient.getConnectionState();
|
|
85
|
+
if (currentState !== 'connected') {
|
|
86
|
+
await globalClient.start();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const actualState = globalClient.getConnectionState();
|
|
90
|
+
const peers = await globalClient.getPeers();
|
|
91
|
+
|
|
92
|
+
setConnectionState(actualState);
|
|
93
|
+
setPeers(peers);
|
|
94
|
+
} catch (err: any) {
|
|
95
|
+
setError(err.message);
|
|
96
|
+
setConnectionState('error');
|
|
97
|
+
}
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!vaultNodeRegistryAddress) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const initializeClient = async () => {
|
|
106
|
+
// Don't reinitialize if client already exists
|
|
107
|
+
if (globalClient) {
|
|
108
|
+
console.log('[ByteCaveProvider] Client already exists, skipping initialization');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log('[ByteCaveProvider] Creating new ByteCaveClient');
|
|
113
|
+
globalClient = new ByteCaveClient({
|
|
114
|
+
vaultNodeRegistryAddress,
|
|
115
|
+
contentRegistryAddress,
|
|
116
|
+
rpcUrl,
|
|
117
|
+
appId,
|
|
118
|
+
directNodeAddrs,
|
|
119
|
+
relayPeers,
|
|
120
|
+
maxPeers: 10,
|
|
121
|
+
connectionTimeout: 30000
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const client = globalClient;
|
|
125
|
+
|
|
126
|
+
const handleStateChange = (state: ConnectionState) => {
|
|
127
|
+
setConnectionState(state);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handlePeerUpdate = async () => {
|
|
131
|
+
if (!client) return;
|
|
132
|
+
try {
|
|
133
|
+
const peers = await client.getPeers();
|
|
134
|
+
setPeers(peers);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Silent
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
client.on('connectionStateChange', handleStateChange);
|
|
141
|
+
client.on('peerConnect', handlePeerUpdate);
|
|
142
|
+
client.on('peerDisconnect', handlePeerUpdate);
|
|
143
|
+
|
|
144
|
+
const hasPeers = directNodeAddrs.length > 0 || relayPeers.length > 0;
|
|
145
|
+
|
|
146
|
+
console.log('[ByteCaveProvider] Auto-connect check:', {
|
|
147
|
+
hasPeers,
|
|
148
|
+
directNodeAddrs: directNodeAddrs.length,
|
|
149
|
+
relayPeers: relayPeers.length,
|
|
150
|
+
connectCalled: connectCalledRef.current
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (hasPeers && !connectCalledRef.current) {
|
|
154
|
+
connectCalledRef.current = true;
|
|
155
|
+
console.log('[ByteCaveProvider] Auto-connecting in 100ms...');
|
|
156
|
+
setTimeout(() => {
|
|
157
|
+
console.log('[ByteCaveProvider] Calling connect()...');
|
|
158
|
+
connect();
|
|
159
|
+
}, 100);
|
|
160
|
+
} else if (!hasPeers) {
|
|
161
|
+
console.warn('[ByteCaveProvider] No relay peers or direct node addresses configured - cannot auto-connect');
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
initializeClient();
|
|
166
|
+
|
|
167
|
+
return () => {
|
|
168
|
+
if (globalClient) {
|
|
169
|
+
globalClient.off('connectionStateChange', () => {});
|
|
170
|
+
globalClient.off('peerConnect', () => {});
|
|
171
|
+
globalClient.off('peerDisconnect', () => {});
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}, []); // Empty deps - only initialize once on mount
|
|
175
|
+
|
|
176
|
+
// Auto-reconnect when peers drop to 0
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const reconnectInterval = setInterval(async () => {
|
|
179
|
+
if (peers.length === 0 && connectionState === 'connected' && globalClient) {
|
|
180
|
+
console.log('[ByteCaveProvider] No peers connected, attempting reconnection...');
|
|
181
|
+
try {
|
|
182
|
+
await connect();
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.error('[ByteCaveProvider] Reconnection failed:', err);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}, 5000);
|
|
188
|
+
|
|
189
|
+
return () => clearInterval(reconnectInterval);
|
|
190
|
+
}, [peers.length, connectionState, connect]);
|
|
191
|
+
|
|
192
|
+
// Periodic peer directory refresh to rediscover nodes
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const refreshInterval = setInterval(async () => {
|
|
195
|
+
if (connectionState === 'connected' && globalClient) {
|
|
196
|
+
console.log('[ByteCaveProvider] Refreshing peer directory...');
|
|
197
|
+
try {
|
|
198
|
+
await globalClient.refreshPeerDirectory();
|
|
199
|
+
const updatedPeers = await globalClient.getPeers();
|
|
200
|
+
setPeers(updatedPeers);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.error('[ByteCaveProvider] Peer directory refresh failed:', err);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}, 30000); // Refresh every 30 seconds
|
|
206
|
+
|
|
207
|
+
return () => clearInterval(refreshInterval);
|
|
208
|
+
}, [connectionState]);
|
|
209
|
+
|
|
210
|
+
const disconnect = async () => {
|
|
211
|
+
if (!globalClient) return;
|
|
212
|
+
try {
|
|
213
|
+
await globalClient.stop();
|
|
214
|
+
setPeers([]);
|
|
215
|
+
} catch (err: any) {
|
|
216
|
+
setError(err.message);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const store = async (data: Uint8Array, mimeType?: string, signer?: any): Promise<StoreResult> => {
|
|
221
|
+
if (!globalClient) {
|
|
222
|
+
return { success: false, error: 'Client not initialized' };
|
|
223
|
+
}
|
|
224
|
+
return (globalClient as any).store(data, mimeType, signer);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const retrieve = async (cid: string): Promise<RetrieveResult> => {
|
|
228
|
+
if (!globalClient) {
|
|
229
|
+
return { success: false, error: 'Client not initialized' };
|
|
230
|
+
}
|
|
231
|
+
return globalClient.retrieve(cid);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const getNodeHealth = async (peerId: string): Promise<NodeHealth | null> => {
|
|
235
|
+
if (!globalClient) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
return (globalClient as any).getNodeHealth(peerId);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const registerContent = async (cid: string, appId: string, signer: any): Promise<{ success: boolean; txHash?: string; error?: string }> => {
|
|
242
|
+
if (!globalClient) {
|
|
243
|
+
return { success: false, error: 'Client not initialized' };
|
|
244
|
+
}
|
|
245
|
+
return globalClient.registerContent(cid, appId, signer);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const value: ByteCaveContextValue = {
|
|
249
|
+
connectionState,
|
|
250
|
+
peers,
|
|
251
|
+
isConnected: connectionState === 'connected',
|
|
252
|
+
appId,
|
|
253
|
+
connect,
|
|
254
|
+
disconnect,
|
|
255
|
+
store,
|
|
256
|
+
retrieve,
|
|
257
|
+
registerContent,
|
|
258
|
+
getNodeHealth,
|
|
259
|
+
error
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<ByteCaveContext.Provider value={value}>
|
|
264
|
+
{children}
|
|
265
|
+
</ByteCaveContext.Provider>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function useByteCaveContext() {
|
|
270
|
+
const context = useContext(ByteCaveContext);
|
|
271
|
+
if (!context) {
|
|
272
|
+
throw new Error('useByteCaveContext must be used within ByteCaveProvider');
|
|
273
|
+
}
|
|
274
|
+
return context;
|
|
275
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Components for HashD Protocol
|
|
3
|
+
*
|
|
4
|
+
* Drop-in components for loading content from ByteCave network
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { ImgHTMLAttributes, VideoHTMLAttributes, AudioHTMLAttributes } from 'react';
|
|
8
|
+
import { useHashdImage, useHashdMedia, type UseHashdContentOptions } from './hooks.js';
|
|
9
|
+
|
|
10
|
+
export interface HashdImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
|
11
|
+
src: string;
|
|
12
|
+
client: UseHashdContentOptions['client'];
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
loadingComponent?: React.ReactNode;
|
|
15
|
+
errorComponent?: React.ReactNode;
|
|
16
|
+
onHashdLoad?: () => void;
|
|
17
|
+
onHashdError?: (error: Error) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Drop-in replacement for <img> that loads from hashd:// URLs
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* <HashdImage
|
|
25
|
+
* src="hashd://abc123..."
|
|
26
|
+
* client={byteCaveClient}
|
|
27
|
+
* alt="Profile picture"
|
|
28
|
+
* className="w-32 h-32 rounded-full"
|
|
29
|
+
* />
|
|
30
|
+
*/
|
|
31
|
+
export function HashdImage({
|
|
32
|
+
src,
|
|
33
|
+
client,
|
|
34
|
+
placeholder,
|
|
35
|
+
loadingComponent,
|
|
36
|
+
errorComponent,
|
|
37
|
+
onHashdLoad,
|
|
38
|
+
onHashdError,
|
|
39
|
+
...imgProps
|
|
40
|
+
}: HashdImageProps) {
|
|
41
|
+
const { src: blobUrl, loading, error } = useHashdImage(src, {
|
|
42
|
+
client,
|
|
43
|
+
placeholder,
|
|
44
|
+
onSuccess: onHashdLoad,
|
|
45
|
+
onError: onHashdError
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (loading && loadingComponent) {
|
|
49
|
+
return <>{loadingComponent}</>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (error && errorComponent) {
|
|
53
|
+
return <>{errorComponent}</>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return <img {...imgProps} src={blobUrl} />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface HashdVideoProps extends Omit<VideoHTMLAttributes<HTMLVideoElement>, 'src'> {
|
|
60
|
+
src: string;
|
|
61
|
+
client: UseHashdContentOptions['client'];
|
|
62
|
+
loadingComponent?: React.ReactNode;
|
|
63
|
+
errorComponent?: React.ReactNode;
|
|
64
|
+
onHashdLoad?: () => void;
|
|
65
|
+
onHashdError?: (error: Error) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drop-in replacement for <video> that loads from hashd:// URLs
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* <HashdVideo
|
|
73
|
+
* src="hashd://abc123..."
|
|
74
|
+
* client={byteCaveClient}
|
|
75
|
+
* controls
|
|
76
|
+
* className="w-full"
|
|
77
|
+
* />
|
|
78
|
+
*/
|
|
79
|
+
export function HashdVideo({
|
|
80
|
+
src,
|
|
81
|
+
client,
|
|
82
|
+
loadingComponent,
|
|
83
|
+
errorComponent,
|
|
84
|
+
onHashdLoad,
|
|
85
|
+
onHashdError,
|
|
86
|
+
...videoProps
|
|
87
|
+
}: HashdVideoProps) {
|
|
88
|
+
const { src: blobUrl, loading, error } = useHashdMedia(src, {
|
|
89
|
+
client,
|
|
90
|
+
onSuccess: onHashdLoad,
|
|
91
|
+
onError: onHashdError
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (loading && loadingComponent) {
|
|
95
|
+
return <>{loadingComponent}</>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (error && errorComponent) {
|
|
99
|
+
return <>{errorComponent}</>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return <video {...videoProps} src={blobUrl} />;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface HashdAudioProps extends Omit<AudioHTMLAttributes<HTMLAudioElement>, 'src'> {
|
|
106
|
+
src: string;
|
|
107
|
+
client: UseHashdContentOptions['client'];
|
|
108
|
+
loadingComponent?: React.ReactNode;
|
|
109
|
+
errorComponent?: React.ReactNode;
|
|
110
|
+
onHashdLoad?: () => void;
|
|
111
|
+
onHashdError?: (error: Error) => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Drop-in replacement for <audio> that loads from hashd:// URLs
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* <HashdAudio
|
|
119
|
+
* src="hashd://abc123..."
|
|
120
|
+
* client={byteCaveClient}
|
|
121
|
+
* controls
|
|
122
|
+
* />
|
|
123
|
+
*/
|
|
124
|
+
export function HashdAudio({
|
|
125
|
+
src,
|
|
126
|
+
client,
|
|
127
|
+
loadingComponent,
|
|
128
|
+
errorComponent,
|
|
129
|
+
onHashdLoad,
|
|
130
|
+
onHashdError,
|
|
131
|
+
...audioProps
|
|
132
|
+
}: HashdAudioProps) {
|
|
133
|
+
const { src: blobUrl, loading, error } = useHashdMedia(src, {
|
|
134
|
+
client,
|
|
135
|
+
onSuccess: onHashdLoad,
|
|
136
|
+
onError: onHashdError
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (loading && loadingComponent) {
|
|
140
|
+
return <>{loadingComponent}</>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (error && errorComponent) {
|
|
144
|
+
return <>{errorComponent}</>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return <audio {...audioProps} src={blobUrl} />;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface HashdContentProps {
|
|
151
|
+
url: string;
|
|
152
|
+
client: UseHashdContentOptions['client'];
|
|
153
|
+
children: (props: {
|
|
154
|
+
blobUrl: string | null;
|
|
155
|
+
loading: boolean;
|
|
156
|
+
error: Error | null;
|
|
157
|
+
mimeType: string | null;
|
|
158
|
+
}) => React.ReactNode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Render prop component for custom content rendering
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* <HashdContent url="hashd://abc123..." client={client}>
|
|
166
|
+
* {({ blobUrl, loading, error }) => {
|
|
167
|
+
* if (loading) return <Spinner />;
|
|
168
|
+
* if (error) return <Error message={error.message} />;
|
|
169
|
+
* return <img src={blobUrl} />;
|
|
170
|
+
* }}
|
|
171
|
+
* </HashdContent>
|
|
172
|
+
*/
|
|
173
|
+
export function HashdContent({ url, client, children }: HashdContentProps) {
|
|
174
|
+
const { blobUrl, loading, error, mimeType } = useHashdImage(url, { client });
|
|
175
|
+
|
|
176
|
+
return <>{children({ blobUrl, loading, error, mimeType })}</>;
|
|
177
|
+
}
|