Skip to content

Commit

Permalink
feat: check service dependencies on startup
Browse files Browse the repository at this point in the history
Allows services to optionally define the capabilities they provide to the
rest of libp2p and also the capabilities they require from other services.

This allows, for example, the `WebRTC` transport to require the `CircuitRelay`
transport to be present, or `KAD-DHT` (or anything that uses a topology)
to require the identify protocol.

Fixes #2263
Refs #2135
  • Loading branch information
achingbrain committed Jun 12, 2024
1 parent 863b3de commit db7d5e5
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 2 deletions.
18 changes: 18 additions & 0 deletions packages/interface/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,24 @@ export interface RoutingOptions extends AbortOptions, ProgressOptions {
useCache?: boolean
}

/**
* This symbol is used by libp2p services to define the capabilities they can
* provide to other libp2p services.
*
* The service should define a property with this symbol as the key and the
* value should be a string array of provided capabilities.
*/
export const serviceCapabilities = Symbol.for('@libp2p/service-capabilities')

/**
* This symbol is used by libp2p services to define the capabilities they
* require from other libp2p services.
*
* The service should define a property with this symbol as the key and the
* value should be a string array of required capabilities.
*/
export const serviceDependencies = Symbol.for('@libp2p/service-dependencies')

export * from './connection/index.js'
export * from './connection-encrypter/index.js'
export * from './connection-gater/index.js'
Expand Down
40 changes: 39 additions & 1 deletion packages/libp2p/src/components.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CodeError } from '@libp2p/interface'
import { CodeError, serviceCapabilities, serviceDependencies } from '@libp2p/interface'
import { isStartable, type Startable, type Libp2pEvents, type ComponentLogger, type NodeInfo, type ConnectionProtector, type ConnectionGater, type ContentRouting, type TypedEventTarget, type Metrics, type PeerId, type PeerRouting, type PeerStore, type PrivateKey, type Upgrader } from '@libp2p/interface'
import { defaultLogger } from '@libp2p/logger'
import type { AddressManager, ConnectionManager, RandomWalk, Registrar, TransportManager } from '@libp2p/interface-internal'
Expand Down Expand Up @@ -157,3 +157,41 @@ export function defaultComponents (init: ComponentsInit = {}): Components {
// @ts-expect-error component keys are proxied
return proxy
}

export function checkServiceDependencies (components: Components): void {
const serviceCapabilities: Record<string, ConstrainBoolean> = {}

for (const service of Object.values(components.components)) {
for (const capability of getServiceCapabilities(service)) {
serviceCapabilities[capability] = true
}
}

for (const service of Object.values(components.components)) {
for (const capability of getServiceDependencies(service)) {
if (serviceCapabilities[capability] !== true) {
throw new CodeError(`Service "${getServiceName(service)}" required capability "${capability}" but it was not provided by any component, you may need to add additional configuration when creating your node.`, 'ERR_UNMET_SERVICE_DEPENDENCIES')
}
}
}
}

function getServiceCapabilities (service: any): string[] {
if (Array.isArray(service?.[serviceCapabilities])) {
return service[serviceCapabilities]
}

return []
}

function getServiceDependencies (service: any): string[] {
if (Array.isArray(service?.[serviceDependencies])) {
return service[serviceDependencies]
}

return []
}

function getServiceName (service: any): string {
return service?.[Symbol.toStringTag] ?? service?.toString() ?? 'unknown'
}
5 changes: 4 additions & 1 deletion packages/libp2p/src/libp2p.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { MemoryDatastore } from 'datastore-core/memory'
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import { DefaultAddressManager } from './address-manager/index.js'
import { defaultComponents } from './components.js'
import { checkServiceDependencies, defaultComponents } from './components.js'
import { connectionGater } from './config/connection-gater.js'
import { validateConfig } from './config.js'
import { DefaultConnectionManager } from './connection-manager/index.js'
Expand Down Expand Up @@ -187,6 +187,9 @@ export class Libp2pNode<T extends ServiceMap = Record<string, unknown>> extends
}
}
}

// Ensure all services have their required dependencies
checkServiceDependencies(components)
}

private configureComponent <T> (name: string, component: T): T {
Expand Down
69 changes: 69 additions & 0 deletions packages/libp2p/test/core/service-dependencies.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { serviceCapabilities, serviceDependencies, stop } from '@libp2p/interface'
import { expect } from 'aegir/chai'
import { createLibp2p } from '../../src/index.js'
import type { Libp2p } from '@libp2p/interface'

/**
* A service with no dependencies
*/
function serviceA () {
return () => {
return {
[serviceCapabilities]: [
'@libp2p/service-a'
]
}
}
}

/**
* A service with a dependency on service A
*/
function serviceB () {
return () => {
return {
[Symbol.toStringTag]: 'service-b',
[serviceDependencies]: [
'@libp2p/service-a'
]
}
}
}

describe('service dependencies', () => {
let node: Libp2p

afterEach(async () => {
await stop(node)
})

it('should start when services have no dependencies', async () => {
node = await createLibp2p({
services: {
a: serviceA()
}
})

expect(node).to.be.ok()
})

it('should error when service dependencies are unmet', async () => {
await expect(createLibp2p({
services: {
b: serviceB()
}
})).to.eventually.be.rejected
.with.property('code', 'ERR_UNMET_SERVICE_DEPENDENCIES')
})

it('should not error when service dependencies are met', async () => {
node = await createLibp2p({
services: {
a: serviceA(),
b: serviceB()
}
})

expect(node).to.be.ok()
})
})

0 comments on commit db7d5e5

Please sign in to comment.