UNPKG

filecoin-pin

Version:

Bridge IPFS content to Filecoin Onchain Cloud using familiar tools

528 lines (468 loc) 16 kB
import { EventEmitter } from 'node:events' import { readFile, unlink } from 'node:fs/promises' import { join } from 'node:path' import type { Helia } from 'helia' import type { CID } from 'multiformats/cid' import type { Logger } from 'pino' import type { CARBlockstoreStats, CARWritingBlockstore } from './core/car/index.js' import type { Config, SynapseService } from './core/synapse/index.js' import { uploadToSynapse } from './core/upload/index.js' import { createPinningHeliaNode } from './create-pinning-helia.js' export interface PinningServiceUser { id: string name: string } export interface PinOptions { name?: string origins?: string[] meta?: Record<string, string> } export interface StoredPinStatus { id: string status: 'queued' | 'pinning' | 'pinned' | 'failed' created: number pin: { cid: string name?: string origins?: string[] meta?: Record<string, string> } info?: Record<string, string> } export interface FilecoinPinMetadata { carFilePath: string carStats: CARBlockstoreStats pinStarted: number pinCompleted?: number synapsePieceCid?: string synapsePieceId?: number synapseDataSetId?: string } export interface FilecoinStoredPinStatus extends StoredPinStatus { filecoin?: FilecoinPinMetadata } export interface FilecoinPinStoreInit { config: Config logger: Logger synapseService: SynapseService } /** * Filecoin-specific pin store that creates CAR files for each pin operation */ export class FilecoinPinStore extends EventEmitter { private readonly config: Config private readonly logger: Logger private readonly synapseService: SynapseService private readonly pins = new Map<string, FilecoinStoredPinStatus>() private readonly activePins = new Map< string, { helia: Helia blockstore: CARWritingBlockstore metadata: FilecoinPinMetadata } >() private pinCounter = 0 constructor(init: FilecoinPinStoreInit) { super() this.config = init.config this.logger = init.logger this.synapseService = init.synapseService } async start(): Promise<void> { this.logger.info('Filecoin pin store started') } async stop(): Promise<void> { // Clean up any active pins for (const [pinId, { helia, blockstore }] of this.activePins.entries()) { try { await blockstore.cleanup() await helia.stop() } catch (error) { this.logger.warn({ pinId, error }, 'Error cleaning up active pin during shutdown') } } this.activePins.clear() this.logger.info('Filecoin pin store stopped') } async pin(user: PinningServiceUser, cid: CID, options: PinOptions = {}): Promise<FilecoinStoredPinStatus> { const pinId = `pin-${Date.now()}-${++this.pinCounter}` const pinStarted = Date.now() // Generate CAR file path const carFileName = `${cid.toString()}-${pinStarted}.car` const carFilePath = join(this.config.carStoragePath, carFileName) this.logger.info( { userId: user.id, pinId, cid: cid.toString(), carFilePath, name: options.name, }, 'Starting Filecoin pin operation' ) // Create initial pin record const pinStatus: FilecoinStoredPinStatus = { id: pinId, status: 'queued', created: pinStarted, pin: { cid: cid.toString(), ...(options.name != null && { name: options.name }), ...(options.origins != null && { origins: options.origins }), ...(options.meta != null && { meta: options.meta }), }, filecoin: { carFilePath, carStats: { blocksWritten: 0, missingBlocks: new Set(), totalSize: 0, startTime: pinStarted, finalized: false, }, pinStarted, }, info: { car_file_path: carFilePath, blocks_written: '0', total_size: '0', status: 'initializing', }, } // Store the pin this.pins.set(pinId, pinStatus) // Start the actual pinning process in the background after a small delay setTimeout(() => { this.logger.debug({ pinId }, 'setTimeout callback executing') this._processPinInBackground(pinId, user, cid) .then(() => { this.logger.debug({ pinId }, 'Background processing completed') }) .catch((error) => { this.logger.error( { pinId, userId: user.id, cid: cid.toString(), error: error instanceof Error ? (error.stack ?? error.message) : String(error), }, 'Background pin processing failed' ) // Update pin status to failed const failedPin = this.pins.get(pinId) if (failedPin != null) { failedPin.status = 'failed' failedPin.info = { ...failedPin.info, error: error.message, status: 'failed', } this.pins.set(pinId, failedPin) } }) }, 100) // Small delay to ensure pin starts in 'queued' state return pinStatus } private async _processPinInBackground(pinId: string, user: PinningServiceUser, cid: CID): Promise<void> { this.logger.debug({ pinId, cid: cid.toString() }, 'Entered _processPinInBackground') const pinStatus = this.pins.get(pinId) if (pinStatus == null || pinStatus.filecoin == null) { this.logger.error({ pinId }, 'Pin not found in _processPinInBackground') throw new Error(`Pin ${pinId} not found`) } try { this.logger.info({ pinId, cid: cid.toString() }, 'Starting background pin processing') // Update status to pinning pinStatus.status = 'pinning' if (pinStatus.info != null) { pinStatus.info.status = 'pinning' } this.pins.set(pinId, pinStatus) // Create a single Helia node with CAR blockstore for this specific pin this.logger.debug({ pinId, cid: cid.toString() }, 'Creating pinning Helia node') const { helia, blockstore } = await createPinningHeliaNode({ config: this.config, logger: this.logger, rootCID: cid, outputPath: pinStatus.filecoin.carFilePath, origins: pinStatus.pin.origins ?? [], }) this.logger.debug({ pinId, cid: cid.toString() }, 'Pinning Helia node created') // Store active pin info this.activePins.set(pinId, { helia, blockstore, metadata: pinStatus.filecoin, }) // Set up event handlers for monitoring blockstore.on('block:stored', (data) => { this.emit('pin:block:stored', { pinId, userId: user.id, cid: data.cid, size: data.size, }) // Update pin status const currentPin = this.pins.get(pinId) if (currentPin?.filecoin != null) { currentPin.filecoin.carStats = blockstore.getStats() currentPin.info = { ...currentPin.info, blocks_written: currentPin.filecoin.carStats.blocksWritten.toString(), total_size: currentPin.filecoin.carStats.totalSize.toString(), } this.pins.set(pinId, currentPin) } }) blockstore.on('block:missing', (data) => { this.emit('pin:block:missing', { pinId, userId: user.id, cid: data.cid, }) }) // Fetch and pin the DAG this.logger.debug({ pinId, cid: cid.toString() }, 'Starting DAG pinning') try { // Use Helia's pin system to walk the full DAG // This will fetch all blocks recursively via Bitswap this.logger.debug({ pinId, cid: cid.toString() }, 'Pinning content via Helia') for await (const pinnedCid of helia.pins.add(cid)) { this.logger.debug( { pinId, cid: cid.toString(), pinnedCid: pinnedCid.toString(), }, 'Block pinned during DAG walk' ) } this.logger.info({ pinId, cid: cid.toString() }, 'Content fully pinned') } catch (error) { this.logger.warn( { pinId, cid: cid.toString(), error: error instanceof Error ? error.message : String(error), }, 'Failed to pin content - some blocks may be missing' ) // Don't throw - we'll finalize the CAR with whatever blocks we got } // Finalize the CAR file const finalStats = await blockstore.finalize() this.logger.info( { pinId, cid: cid.toString(), blocksWritten: finalStats.blocksWritten, totalSize: finalStats.totalSize, missingBlocks: finalStats.missingBlocks.size, }, 'CAR file finalized' ) // Store on Filecoin using Synapse SDK // This section demonstrates the integration pattern: // 1. Prepare the data (CAR file) for upload // 2. Use Synapse's storage.upload() with callbacks for monitoring // 3. Track the returned piece information in application state // 4. Handle errors gracefully with proper cleanup try { // Read the CAR file (streaming not yet supported in Synapse) // TODO: When Synapse supports streaming, this could be optimized const carData = await readFile(pinStatus.filecoin.carFilePath) // Upload using shared function with pinId as context and IPFS root CID metadata const uploadResult = await uploadToSynapse(this.synapseService, carData, cid, this.logger, { contextId: pinId, }) // Store Synapse metadata pinStatus.filecoin.synapsePieceCid = uploadResult.pieceCid if (uploadResult.pieceId !== undefined) { pinStatus.filecoin.synapsePieceId = uploadResult.pieceId } pinStatus.filecoin.synapseDataSetId = uploadResult.dataSetId // Add to info for API response pinStatus.info = { ...pinStatus.info, synapse_piece_cid: uploadResult.pieceCid, synapse_piece_id: (uploadResult.pieceId ?? 0).toString(), synapse_data_set_id: uploadResult.dataSetId, } } catch (error) { // Rollback on Synapse failure this.logger.error( { event: 'synapse.upload.failed', pinId, error, }, 'Failed to upload to Filecoin with Synapse, rolling back' ) // Clean up the CAR file try { await blockstore.cleanup() await unlink(pinStatus.filecoin.carFilePath) this.logger.info( { pinId, carFilePath: pinStatus.filecoin.carFilePath }, 'Deleted CAR file after Synapse failure' ) } catch (cleanupError) { this.logger.warn({ pinId, error: cleanupError }, 'Failed to clean up CAR file') } // Re-throw to mark pin as failed throw new Error(`Synapse upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`) } // Update pin status to completed pinStatus.status = 'pinned' pinStatus.filecoin.carStats = finalStats pinStatus.filecoin.pinCompleted = Date.now() pinStatus.info = { ...pinStatus.info, status: 'pinned', blocks_written: finalStats.blocksWritten.toString(), total_size: finalStats.totalSize.toString(), missing_blocks: finalStats.missingBlocks.size.toString(), pin_duration: (pinStatus.filecoin.pinCompleted - pinStatus.filecoin.pinStarted).toString(), } this.pins.set(pinId, pinStatus) // Emit completion event this.emit('pin:car:completed', { pinId, userId: user.id, cid, stats: finalStats, carFilePath: pinStatus.filecoin.carFilePath, }) this.logger.info({ pinId, cid: cid.toString() }, 'Pin processing completed successfully') } catch (error) { this.logger.error({ pinId, cid: cid.toString(), error }, 'Pin processing failed') // Update pin status to failed pinStatus.status = 'failed' pinStatus.info = { ...pinStatus.info, status: 'failed', error: error instanceof Error ? error.message : 'Unknown error', } this.pins.set(pinId, pinStatus) this.emit('pin:failed', { pinId, userId: user.id, cid, error, }) } finally { // Clean up active pin const activePin = this.activePins.get(pinId) if (activePin != null) { await activePin.helia.stop() this.activePins.delete(pinId) } } } async get(_user: PinningServiceUser, id: string): Promise<FilecoinStoredPinStatus | undefined> { return this.pins.get(id) } async update( _user: PinningServiceUser, id: string, options: PinOptions ): Promise<FilecoinStoredPinStatus | undefined> { const pinStatus = this.pins.get(id) if (pinStatus == null) { return undefined } // Update the pin object if (options.name !== undefined) { pinStatus.pin.name = options.name } if (options.origins !== undefined) { pinStatus.pin.origins = options.origins } if (options.meta !== undefined) { pinStatus.pin.meta = { ...pinStatus.pin.meta, ...options.meta } } this.pins.set(id, pinStatus) return pinStatus } async cancel(_user: PinningServiceUser, id: string): Promise<void> { // Get the pin to find the CAR file path const pin = this.pins.get(id) // Clean up active pin if it exists const activePin = this.activePins.get(id) if (activePin != null) { try { await activePin.blockstore.cleanup() await activePin.helia.stop() } catch (error) { this.logger.warn({ pinId: id, error }, 'Error cleaning up cancelled pin') } this.activePins.delete(id) } // Delete the CAR file if it exists if (pin?.filecoin?.carFilePath != null) { try { await unlink(pin.filecoin.carFilePath) this.logger.info({ pinId: id, carFilePath: pin.filecoin.carFilePath }, 'Deleted CAR file for cancelled pin') } catch (error: any) { // Only warn if it's not a "file not found" error if (error.code !== 'ENOENT') { this.logger.warn({ pinId: id, carFilePath: pin.filecoin.carFilePath, error }, 'Failed to delete CAR file') } } } // Remove the pin from memory this.pins.delete(id) } async list( _user: PinningServiceUser, query?: { cid?: string name?: string status?: string limit?: number } ): Promise<{ count: number results: FilecoinStoredPinStatus[] }> { let results = Array.from(this.pins.values()) // Apply filters if (query?.cid != null && query.cid.length > 0) { results = results.filter((pin) => pin.pin.cid === query.cid) } if (query?.name != null && query.name.length > 0) { const nameFilter = query.name results = results.filter((pin) => pin.pin.name?.includes(nameFilter) === true) } if (query?.status != null) { results = results.filter((pin) => pin.status === query.status) } // Apply limit if (query?.limit != null && query.limit > 0) { results = results.slice(0, query.limit) } // Sort by created date (newest first) results.sort((a, b) => b.created - a.created) return { count: results.length, results, } } /** * Get statistics for all active pins */ getActivePinStats(): Array<{ pinId: string cid: string stats: CARBlockstoreStats duration: number }> { return Array.from(this.activePins.entries()).map(([pinId, { metadata }]) => ({ pinId, cid: metadata.carFilePath.split('/').pop()?.split('-')[0] ?? 'unknown', stats: metadata.carStats, duration: Date.now() - metadata.pinStarted, })) } }