UNPKG

@simplepg/dservice

Version:

JavaScript implementation of the SimplePage backend service

454 lines (409 loc) 15.3 kB
import { create } from 'kubo-rpc-client' import { CarBlock } from 'cartonne' import { CID } from 'multiformats/cid' import { identity } from 'multiformats/hashes/identity' import varint from 'varint' import all from 'it-all' import * as u8a from 'uint8arrays' import { assert, carFromBytes, emptyCar, CidSet } from '@simplepg/common' import { FinalizationMap } from './finalization-map.js' import { LRUCache } from 'lru-cache' const BLOCK_NUMBER_LABEL = 'spg_latest_block_number' const dataTypeToCidEncodeFn = dataType => { if (dataType === 'string') { return s => CID.create(1, 0x55, identity.digest(u8a.fromString(s, 'utf8'))) } else if (dataType === 'address') { return a => { const hexAddr = a.slice(2).toLowerCase() return CID.create(1, 0x55, identity.digest(u8a.fromString(hexAddr, 'hex'))) } } else if (dataType === 'number') { return n => { const encoded = new Uint8Array(varint.encode(n)) return CID.create(1, 0x55, identity.digest(encoded)) } } else { throw new Error(`Unsupported data type: ${dataType}`) } } const dataTypeToCidDecodeFn = dataType => { if (dataType === 'string') { return cid => u8a.toString(cid.multihash.digest, 'utf8') } else if (dataType === 'address') { return cid => '0x' + u8a.toString(cid.multihash.digest, 'hex') } else if (dataType === 'number') { return cid => varint.decode(cid.multihash.digest) } else { throw new Error(`Unsupported data type: ${dataType}`) } } export class IpfsService { constructor({ api, ipfsClient, maxStagedAge = 60 * 60, logger }) { // Default 1 hour assert(api || ipfsClient, 'api or ipfsClient must be provided') this.client = ipfsClient || create({ url: api }) this.maxStagedAge = maxStagedAge this.logger = logger || { info: () => {}, debug: () => {}, error: () => {}, warn: () => {} } this.finalizations = new FinalizationMap(this.client, this.logger) this._listCache = new LRUCache({ max: 100, ttl: 1000 * 60 * 5 }) // 5 min TTL this._lastPruneStaged = 0 } async writeCar(fileBuffer, stageDomain) { try { this.logger.info('Writing CAR file', { stageDomain, bufferSize: fileBuffer.length }) let rootCid // Create an AsyncIterable that yields CAR files as Uint8Arrays const sources = (async function* () { yield new Uint8Array(fileBuffer) })() for await (const result of this.client.dag.import(sources)) { rootCid = result.root.cid } // Create pin label with timestamp const label = `spg_staged_${stageDomain}_${Math.floor(Date.now() / 1000)}` // Pin the content recursively with the label await this.client.pin.add(rootCid, { recursive: true, name: label }) this.logger.info('Staged CAR file successfully', { rootCid: rootCid.toString(), label, stageDomain }) return rootCid } catch (error) { this.logger.error('Error importing CAR file', { error: error.message, stageDomain, stack: error.stack }) throw error } } async healthCheck() { try { await this.client.id() this.logger.debug('IPFS health check passed') return true } catch (error) { this.logger.error('IPFS health check failed', { error: error.message, stack: error.stack }) return false } } async #readCar(cid) { return carFromBytes(Buffer.concat(await all(this.client.dag.export(cid))), { verify: false }) } async readCarLite(cid) { // Helper to recursively collect needed CIDs const neededCids = new Set([cid]) const filesToCollect = ["index.html", "index.md", "_template.html", "manifest.webmanifest", "manifest.json"] const self = this async function collectFiles(currentCid, path = '', isRoot = false) { for await (const entry of self.client.ls(currentCid)) { // Only descend into directories that don't start with _ if at root if (entry.type === 'dir') { if (isRoot && entry.name.startsWith('_') && entry.name !== '_files') continue neededCids.add(entry.cid.toString()) await collectFiles(entry.cid, path + entry.name + '/', false) } else if (filesToCollect.includes(entry.name) && !path.includes('_files')) { neededCids.add(entry.cid.toString()) } } } await collectFiles(cid, '', true) // Create a new CAR writer const car = emptyCar() // Add blocks for each needed CID for (const blockCid of neededCids) { const blockData = await this.client.block.get(blockCid) car.blocks.put(new CarBlock(CID.parse(blockCid), blockData)) } // Add the root CID to the CAR car.roots.push(CID.parse(cid)) return car.bytes } async readBlock(cid) { try { this.logger.debug('Reading raw IPFS block', { cid }) const blockData = await this.client.block.get(cid) this.logger.debug('Raw IPFS block read successfully', { cid, blockSize: blockData.length }) return blockData } catch (error) { this.logger.error('Error reading raw IPFS block', { cid, error: error.message, stack: error.stack }) throw error } } async isPageFinalized(cid, domain, blockNumber) { assert(cid instanceof CID, `cid must be an instance of CID, got ${typeof cid}`) return this.finalizations.isFinalized(domain, blockNumber, cid) } async providePage(cid) { // Recursively walk the DAG from cid, skipping any path containing '_prev' const cids = new CidSet(); const self = this; async function walk(currentCid, path = '') { cids.add(currentCid); for await (const entry of self.client.ls(currentCid)) { const entryPath = `${path}/${entry.name}`; // Skip any entry under a _prev directory at any level if (entry.name === '_prev') continue; cids.add(entry.cid); if (entry.type === 'dir') { await walk(entry.cid, entryPath); } } } await walk(cid, ''); // Provide all collected CIDs in a batch try { await all(await this.client.routing.provide(Array.from(cids))) this.logger.info('Provided all CIDs for page', { root: cid.toString(), count: cids.size }); } catch (error) { this.logger.error('Error providing page', { error: error.message, cid: cid.toString(), stack: error.stack }) } } async finalizePage(cid, domain, blockNumber) { assert(cid instanceof CID, `cid must be an instance of CID, got ${typeof cid}`) try { this.logger.info('Finalizing page', { cid: cid.toString(), domain, blockNumber }) // Add finalization using the FinalizationMap await this.finalizations.push(domain, blockNumber, cid) this.logger.info('Page finalized successfully', { cid: cid.toString(), domain, blockNumber }) // Reprovide all new CIDs of the published page await this.providePage(cid) // Remove all staged pins const stagedPins = await this.client.pin.ls({ name: `spg_staged_${domain}` }) for await (const pin of stagedPins) { await this.client.pin.rm(pin.cid, { recursive: true }) this.logger.debug('Removed staged pin', { pinName: pin.name, pinCid: pin.cid.toString() }) } } catch (error) { this.logger.error('Error finalizing page', { error: error.message, cid: cid.toString(), domain, blockNumber, stack: error.stack }) // throw error } } async listFinalizedPages() { return this.finalizations.list() } async nukePage(domain) { try { this.logger.info('Nuking page', { domain }) // Get current finalizations for the domain const domainFinalizations = await this.finalizations.getAll(domain) if (domainFinalizations.length === 0) { this.logger.debug('No finalizations found for domain', { domain }) return } // Collect all CIDs that need to be checked for removal const cidsToCheck = [] for (const { cid } of domainFinalizations) { const recursiveCids = await this.#collectChildCids(cid) cidsToCheck.push(...recursiveCids) } // Remove domain from finalizations await this.finalizations.remove(domain) this.logger.info('Removed domain from finalizations', { domain, finalizationsRemoved: domainFinalizations.length }) const cidsWithOtherDomainPins = [] for (const cid of cidsToCheck) { try { const pins = await all(this.client.pin.ls({ paths: [cid] })) if (pins.length > 0) { cidsWithOtherDomainPins.push(pins[0].cid) } } catch (error) { continue // Ignore errors from ls when CID is not pinned } } const cidsToNuke = cidsToCheck.filter(checkCid => !cidsWithOtherDomainPins.find(keepCid => keepCid.equals(checkCid))) for (const cid of cidsToNuke) { try { await all(await this.client.block.rm(cid)) this.logger.debug('Removed block', { cid: cid.toString() }) } catch (error) { // Block might already be removed or not exist this.logger.debug('Block already removed or doesn\'t exist', { cid: cid.toString() }) } } this.logger.info('Page nuked successfully', { domain, finalizationsRemoved: domainFinalizations.length, blocksRemoved: cidsToNuke.length }) } catch (error) { this.logger.error('Error nuking page', { error: error.message, domain, stack: error.stack }) throw error } } async #collectChildCids(rootCid) { try { const car = await this.#readCar(rootCid) const cids = await all(car.blocks.cids()) return cids } catch (error) { this.logger.warn('Could not collect children for CID', { rootCid: rootCid.toString(), error: error.message }) } } async pruneStaged() { const now = Math.floor(Date.now() / 1000) if (this._lastPruneStaged && (now - this._lastPruneStaged < 60)) { this.logger && this.logger.debug && this.logger.debug('pruneStaged: Skipping, called too soon') return } this._lastPruneStaged = now try { this.logger.debug('Starting staged pin pruning') // Get all pins with spg_staged_ prefix const stagedPins = await this.client.pin.ls({ name: 'spg_staged_' }) let prunedCount = 0 for await (const pin of stagedPins) { // Extract timestamp from pin name // Format is spg_staged_domain_timestamp const parts = pin.name.split('_') if (parts.length >= 4) { const timestamp = parseInt(parts[parts.length - 1], 10) if (!isNaN(timestamp)) { const age = now - timestamp if (age > this.maxStagedAge) { await this.client.pin.rm(pin.cid, { recursive: true }) this.logger.info('Pruned old staged pin', { pinName: pin.name, pinCid: pin.cid.toString(), age: age }) prunedCount++ } } } } if (prunedCount > 0) { this.logger.info('Staged pin pruning completed', { prunedCount }) } } catch (error) { this.logger.error('Error pruning staged pins', { error: error.message, stack: error.stack }) throw error } } async _getList(name, dataType) { if (this._listCache.has(name)) { return this._listCache.get(name) } const pins = await all(await this.client.pin.ls({ name: `spg_list_${name}` })) const decodeFn = dataTypeToCidDecodeFn(dataType) const result = pins.map(pin => decodeFn(pin.cid)) this._listCache.set(name, result) return result } async getList(name, dataType) { return this._getList(name, dataType) } async addToList(name, dataType, value) { const list = await this._getList(name, dataType) const encodeFn = dataTypeToCidEncodeFn(dataType) const itemCid = encodeFn(value) if (!list.includes(value)) { await this.client.pin.add(itemCid, { name: `spg_list_${name}`, recursive: false }) this.logger.info('Added item to list', { name, dataType, value }) this._listCache.delete(name) } else { this.logger.debug('Item already in list', { name, dataType, value }) } } async removeFromList(name, dataType, value) { this.logger.debug('Removing from list', { name, dataType, value }) const list = await this._getList(name, dataType) if (list.includes(value)) { const itemCid = dataTypeToCidEncodeFn(dataType)(value) await this.client.pin.rm(itemCid, { name: `spg_list_${name}` }) this.logger.info('Removed item from list', { name, dataType, value }) this._listCache.delete(name) } else { this.logger.debug('Item not in list', { name, dataType, value }) } } async _getLatestBlockNumberCid() { const pins = await all(await this.client.pin.ls({ name: 'spg_latest_block_number' })) assert(pins.length <= 1, 'Expected max one latest block number pin, got ' + pins.length) return pins[0]?.cid } async getLatestBlockNumber() { if (this._listCache.has(BLOCK_NUMBER_LABEL)) { return this._listCache.get(BLOCK_NUMBER_LABEL); } const cid = await this._getLatestBlockNumberCid() if (!cid) { this.logger.debug('No latest block number found, returning 0') this._listCache.set(BLOCK_NUMBER_LABEL, 0); return 0 } // Decode the CID to get the block number const blockNumber = dataTypeToCidDecodeFn('number')(cid) this.logger.debug('Retrieved latest block number', { blockNumber }) this._listCache.set(BLOCK_NUMBER_LABEL, blockNumber); return blockNumber } async setLatestBlockNumber(blockNumber) { this.logger.debug('Setting latest block number', { blockNumber }) // get current latest block number cid const latestBlockNumberCid = await this._getLatestBlockNumberCid() // generate given block number cid const newBlockNumberCid = dataTypeToCidEncodeFn('number')(blockNumber) if (!latestBlockNumberCid) { await this.client.pin.add(newBlockNumberCid, { name: 'spg_latest_block_number' }) this.logger.info('Created new latest block number pin', { blockNumber }) this._listCache.delete(BLOCK_NUMBER_LABEL); } else if (!latestBlockNumberCid.equals(newBlockNumberCid)) { await this.client.pin.update(latestBlockNumberCid, newBlockNumberCid) this.logger.info('Updated latest block number pin', { oldBlockNumber: dataTypeToCidDecodeFn('number')(latestBlockNumberCid), newBlockNumber: blockNumber }) this._listCache.delete(BLOCK_NUMBER_LABEL); } else { this.logger.debug('Latest block number unchanged', { blockNumber }) } } }