diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index eb1022925b..3d3fe25742 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -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' diff --git a/packages/libp2p/src/components.ts b/packages/libp2p/src/components.ts index 724f8f28a5..508ab2fc33 100644 --- a/packages/libp2p/src/components.ts +++ b/packages/libp2p/src/components.ts @@ -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' @@ -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 = {} + + 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' +} diff --git a/packages/libp2p/src/libp2p.ts b/packages/libp2p/src/libp2p.ts index 351460092e..73b2c219d0 100644 --- a/packages/libp2p/src/libp2p.ts +++ b/packages/libp2p/src/libp2p.ts @@ -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' @@ -187,6 +187,9 @@ export class Libp2pNode> extends } } } + + // Ensure all services have their required dependencies + checkServiceDependencies(components.components) } private configureComponent (name: string, component: T): T { diff --git a/packages/libp2p/test/core/service-dependencies.spec.ts b/packages/libp2p/test/core/service-dependencies.spec.ts new file mode 100644 index 0000000000..5abbf8499a --- /dev/null +++ b/packages/libp2p/test/core/service-dependencies.spec.ts @@ -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.only('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() + }) +})