Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create @helia/remote-pinner library #2

Merged
merged 18 commits into from
Aug 29, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,50 @@
$ npm i @helia/remote-pinning
```

A longer repository description.

## Documentation

[Insert link to documentation]() or expand with Install, Build, Usage sections.
### Create remote pinner

```typescript
import { unixfs } from '@helia/unixfs'
import { Configuration, RemotePinningServiceClient } from '@ipfs-shipyard/pinning-service-client'
import { createHelia } from 'helia'
import { createRemotePinner } from '../src/index.js'
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved

const helia = await createHelia()
const pinServiceConfig = new Configuration({
endpointUrl: `${endpointUrl}`, // the URI for your pinning provider, e.g. `http://localhost:3000`
accessToken: `${accessToken}` // the secret token/key given to you by your pinning provider
})

const remotePinningClient = new RemotePinningServiceClient(pinServiceConfig)
const remotePinner = createRemotePinner(helia, remotePinningClient)
```

### Add a pin

```typescript
const heliaFs = unixfs(helia)
const cid = await heliaFs.addBytes(encoder.encode('hello world'))
const addPinResult = await remotePinner.addPin({
cid,
name: 'helloWorld'
})
```


SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
### Replace a pin

```typescript

const newCid = await heliaFs.addBytes(encoder.encode('hi galaxy'))
const replacePinResult = await remotePinner.replacePin({
newCid,
name: 'hiGalaxy',
requestid: addPinResult.requestid
})

SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
```

## Lead Maintainer

Expand Down
5 changes: 5 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* when remote pinning service returns delegates, if we can't connect to any, we won't be able to provide our CID's
* content to the service, and must abort.
*/
export class FailedToConnectToDelegates extends Error {}
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
121 changes: 121 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { type RemotePinningServiceClient, type Pin, type PinStatus, type PinsRequestidPostRequest, Status } from '@ipfs-shipyard/pinning-service-client'
import { multiaddr } from '@multiformats/multiaddr'
import debug from 'debug'
import pRetry from 'p-retry'
import { FailedToConnectToDelegates } from './errors.js'
import type { Helia } from '@helia/interface'
import type { CID } from 'multiformats/cid'

const log = debug('helia-remote-pinning')
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
const logError = log.extend('error')
const logTrace = log.extend('trace')

export interface HeliaRemotePinningOptions {
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
/**
* Control whether requests are aborted or not by manually aborting a signal or using AbortSignal.timeout()
*/
signal?: AbortSignal

/**
* The CID instance to pin. When using Helia, passing around the CID object is preferred over the string.
*/
cid: CID
}

export interface AddPinArgs extends Omit<Pin, 'cid'>, HeliaRemotePinningOptions {}

export interface ReplacePinArgs extends Omit<PinsRequestidPostRequest, 'pin'>, Omit<Pin, 'cid'>, HeliaRemotePinningOptions {}

export class HeliaRemotePinner {
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
constructor (private readonly heliaInstance: Helia, private readonly remotePinningClient: RemotePinningServiceClient) {
}

private async getOrigins (otherOrigins: Pin['origins']): Promise<Set<string>> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering if this can be static method.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think users could just use the pin response directly to get origins, they shouldn't be depending on this method

const origins = new Set(this.heliaInstance.libp2p.getMultiaddrs().map(multiaddr => multiaddr.toString()))
if (otherOrigins != null) {
for (const origin of otherOrigins) {
origins.add(origin)
}
}
return origins
}

private async connectToDelegates (delegates: Set<string>, signal?: AbortSignal): Promise<void> {
let successfulDials = 0
try {
for (const delegate of delegates) {
await this.heliaInstance.libp2p.dial(multiaddr(delegate), { signal })
successfulDials++
}
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
} catch (e) {
logError(e)
}
if (successfulDials === 0) {
throw new FailedToConnectToDelegates('Failed to connect to any delegates')
}
}

/**
* The code that runs after we get a pinStatus from the remote pinning service.
* This method is the orchestrator for waiting for the pin to complete/fail as well as connecting to the delegates.
*/
private async handlePinStatus (pinStatus: PinStatus, signal?: AbortSignal): Promise<PinStatus> {
await this.connectToDelegates(pinStatus.delegates, signal)
let updatedPinStatus = pinStatus

/**
* We need to ensure that pinStatus is either pinned or failed.
* To do so, we will need to poll the remote pinning service for the status of the pin.
*/
try {
await pRetry(async (attemptNum) => {
logTrace('attempt #%d waiting for pinStatus of "pinned" or "failed"', attemptNum)
updatedPinStatus = await this.remotePinningClient.pinsRequestidGet({ requestid: pinStatus.requestid })
if ([Status.Pinned, Status.Failed].includes(pinStatus.status)) {
return updatedPinStatus
}
throw new Error(`Pin status is ${pinStatus.status}`)
}, {
retries: 10,
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
signal
})
} catch (e) {
logError(e)
}

return updatedPinStatus
}

async addPin ({ cid, signal, ...otherArgs }: AddPinArgs): Promise<PinStatus> {
if (signal?.aborted === true) {
throw new Error('Signal was aborted prior to pinning')
}
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
const pinStatus = await this.remotePinningClient.pinsPost({
pin: {
...otherArgs,
cid: cid.toString(),
origins: await this.getOrigins(otherArgs.origins)
}
})
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
return this.handlePinStatus(pinStatus, signal)
}

async replacePin ({ cid, requestid, signal, ...otherArgs }: ReplacePinArgs): Promise<PinStatus> {
if (signal?.aborted === true) {
throw new Error('Signal was aborted prior to pinning')
}
const pinStatus = await this.remotePinningClient.pinsRequestidPost({
requestid,
pin: {
...otherArgs,
cid: cid.toString(),
origins: await this.getOrigins(otherArgs.origins)
}
})
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
return this.handlePinStatus(pinStatus, signal)
}
}

export function createRemotePinner (heliaInstance: Helia, remotePinningClient: RemotePinningServiceClient, options?: HeliaRemotePinningOptions): HeliaRemotePinner {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this interface not be like helia? also an opportunity to leverage mixins

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean. This is a factory function and I think we want it to remain fairly lightweight. it's not doing much users couldn't do themselves, it's just a helper library

SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
return new HeliaRemotePinner(heliaInstance, remotePinningClient)
}
204 changes: 204 additions & 0 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { unixfs, type UnixFS } from '@helia/unixfs'
import { Configuration, RemotePinningServiceClient, Status } from '@ipfs-shipyard/pinning-service-client'
import { expect } from 'aegir/chai'
import { createHelia } from 'helia'
import sinon, { type SinonSandbox, type SinonStub } from 'sinon'
import { FailedToConnectToDelegates } from '../src/errors.js'
import { type HeliaRemotePinner, createRemotePinner } from '../src/index.js'
import type { Helia } from '@helia/interface'

const encoder = new TextEncoder()

describe('@helia/remote-pinning', function () {
let sinonSandbox: SinonSandbox
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
let remotePinner: HeliaRemotePinner
let helia: Helia
let remotePinningClient: RemotePinningServiceClient
let heliaFs: UnixFS
let dialStub: SinonStub

const validatePinResults = async (name: string, count: number): Promise<void> => {
const pinResults = await remotePinningClient.pinsGet({ name })
expect(pinResults.results).to.have.lengthOf(count)
expect(pinResults.count).to.equal(count)
}

beforeEach(async function () {
sinonSandbox = sinon.createSandbox()
helia = await createHelia()
heliaFs = unixfs(helia)
dialStub = sinonSandbox.stub(helia.libp2p, 'dial')
const pinServiceConfig = new Configuration({
endpointUrl: `http://localhost:${process.env.PINNING_SERVER_PORT}`, // the URI for your pinning provider, e.g. `http://localhost:3000`
accessToken: process.env.PINNING_SERVICE_TOKEN // the secret token/key given to you by your pinning provider
})

remotePinningClient = new RemotePinningServiceClient(pinServiceConfig)
remotePinner = createRemotePinner(helia, remotePinningClient)
})
afterEach(async function () {
sinonSandbox.restore()
await helia.stop()
const pins = await remotePinningClient.pinsGet()
await Promise.all([...pins.results].map(pin => remotePinningClient.pinsRequestidDelete({ requestid: pin.requestid })))
})
describe('addPin', function () {
it('Returns pinned status when pinning succeeds', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))
dialStub.returns(Promise.resolve({} as any))
const addPinResult = await remotePinner.addPin({
cid,
name: 'pinned-test1'
})
expect(addPinResult.status).to.equal(Status.Pinned)
})

it('Returns failed status when pinning fails', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))

dialStub.returns(Promise.resolve({} as any))
const addPinResult = await remotePinner.addPin({
cid,
name: 'failed-test1'
})
expect(addPinResult.status).to.equal(Status.Failed)
})

it('will await a queued pin until a signal times out', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))
const abortController = new AbortController()
const thenSpy = sinonSandbox.spy()
const catchSpy = sinonSandbox.spy()
const finallySpy = sinonSandbox.spy()
dialStub.returns(Promise.resolve({} as any))
const addPinResult = remotePinner.addPin({
cid,
name: 'queued-test1',
signal: abortController.signal
})
addPinResult.then(thenSpy).catch(catchSpy).finally(finallySpy)
expect(thenSpy.called).to.equal(false)
expect(catchSpy.called).to.equal(false)
expect(finallySpy.called).to.equal(false)

// we need to wait for X time and then confirm the promise hasn't settled
await new Promise(resolve => setTimeout(resolve, 100))
expect(thenSpy.called).to.equal(false)
expect(catchSpy.called).to.equal(false)
expect(finallySpy.called).to.equal(false)

// note that mock-pinning-service will indefinitely hang on pins with names that start with "queued-"
abortController.abort()
await expect(addPinResult).to.eventually.have.property('status', Status.Queued)

expect(thenSpy.called).to.equal(true)
expect(catchSpy.called).to.equal(false)
expect(finallySpy.called).to.equal(true)
expect(abortController.signal.aborted).to.equal(true)
})

it('Stops listening when provided signal times out', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))
dialStub.returns(Promise.resolve({} as any))
const addPinResult = await remotePinner.addPin({
cid,
name: 'queued-test2',
signal: AbortSignal.timeout(100)
})
expect(addPinResult.status).to.equal(Status.Queued)
})

it('Will not pin if provided an already aborted signal', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))
const abortController = new AbortController()
abortController.abort()
dialStub.returns(Promise.resolve({} as any))
const preAbortedRequest = remotePinner.addPin({
cid,
name: 'queued-test3',
signal: abortController.signal
})
await expect(preAbortedRequest).to.eventually.be.rejectedWith('Signal was aborted prior to pinning')
})

it('Returns FailedToConnectToDelegates when unable to connect to delegates', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))
dialStub.throws(new Error('Stubbed dial failure'))
const addPinResult = remotePinner.addPin({
cid,
name: 'pinned-test2'
})
// stub heliaInstance.libp2p.dial to throw an error
await expect(addPinResult).to.eventually.be.rejectedWith(FailedToConnectToDelegates)
})

it('Does not return FailedToConnectToDelegates when unable to connect to a single delegate', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))
dialStub.onCall(0).returns(Promise.resolve({} as any))
dialStub.onCall(1).throws(new Error('Stubbed dial failure'))
const addPinResult = remotePinner.addPin({
cid,
name: 'pinned-test2'
})

await expect(addPinResult).to.eventually.have.property('status', Status.Pinned)
})

it('can receive additional remote origins', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))
dialStub.returns(Promise.resolve({} as any))
const addPinResult = await remotePinner.addPin({
cid,
name: 'pinned-test4',
origins: new Set(['http://localhost:4444'])
})
expect(addPinResult.status).to.equal(Status.Pinned)
})
})

describe('replacePin', function () {
it('will replace a previously added pin', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))
dialStub.returns(Promise.resolve({} as any))
const addPinResult = await remotePinner.addPin({
cid,
name: 'pinned-test3'
})
expect(addPinResult.status).to.equal(Status.Pinned)
expect(addPinResult.requestid).to.be.a('string')
await validatePinResults('pinned-test3', 1)
await validatePinResults('pinned-test3-replaced', 0)

const replacePinResult = await remotePinner.replacePin({
cid,
name: 'pinned-test3-replaced',
requestid: addPinResult.requestid
})

expect(replacePinResult.status).to.equal(Status.Pinned)
expect(replacePinResult.requestid).not.to.equal(addPinResult.requestid)

await validatePinResults('pinned-test3', 0)
await validatePinResults('pinned-test3-replaced', 1)
})

it('Will not replace the pin if provided an already aborted signal', async function () {
const cid = await heliaFs.addBytes(encoder.encode('hello world'))

const addPinResult = await remotePinner.addPin({
cid,
name: 'pinned-test5'
})
const abortController = new AbortController()
abortController.abort()
dialStub.returns(Promise.resolve({} as any))
const preAbortedRequest = remotePinner.replacePin({
cid,
requestid: addPinResult.requestid,
name: 'queued-test5-replaced',
signal: abortController.signal
})
await expect(preAbortedRequest).to.eventually.be.rejectedWith('Signal was aborted prior to pinning')
})
})
})