import { PackageId, ServiceInterfaceId, ServiceInterfaceType } from '../types' import { knownProtocols } from '../interfaces/Host' import { AddressInfo, Host, Hostname, HostnameInfo } from '../types' import { Effects } from '../Effects' import { DropGenerator, DropPromise } from './Drop' import { IpAddress, IPV6_LINK_LOCAL } from './ip' import { deepEqual } from './deepEqual' import { once } from './once' export type UrlString = string export type HostId = string const getHostnameRegex = /^(\w+:\/\/)?([^\/\:]+)(:\d{1,3})?(\/)?/ export const getHostname = (url: string): Hostname | null => { const founds = url.match(getHostnameRegex)?.[2] if (!founds) return null const parts = founds.split('@') const last = parts[parts.length - 1] as Hostname | null return last } type FilterKinds = | 'mdns' | 'domain' | 'ip' | 'ipv4' | 'ipv6' | 'localhost' | 'link-local' export type Filter = { visibility?: 'public' | 'private' kind?: FilterKinds | FilterKinds[] predicate?: (h: HostnameInfo) => boolean exclude?: Filter } type VisibilityFilter = V extends 'public' ? (HostnameInfo & { public: true }) | VisibilityFilter> : V extends 'private' ? | (HostnameInfo & { public: false }) | VisibilityFilter> : never type KindFilter = K extends 'mdns' ? | (HostnameInfo & { kind: 'ip'; hostname: { kind: 'local' } }) | KindFilter> : K extends 'domain' ? | (HostnameInfo & { kind: 'ip'; hostname: { kind: 'domain' } }) | KindFilter> : K extends 'ipv4' ? | (HostnameInfo & { kind: 'ip'; hostname: { kind: 'ipv4' } }) | KindFilter> : K extends 'ipv6' ? | (HostnameInfo & { kind: 'ip'; hostname: { kind: 'ipv6' } }) | KindFilter> : K extends 'ip' ? KindFilter | 'ipv4' | 'ipv6'> : never type FilterReturnTy = F extends { visibility: infer V extends 'public' | 'private' } ? VisibilityFilter & FilterReturnTy> : F extends { kind: (infer K extends FilterKinds) | (infer K extends FilterKinds)[] } ? KindFilter & FilterReturnTy> : F extends { predicate: (h: HostnameInfo) => h is infer H extends HostnameInfo } ? H & FilterReturnTy> : F extends { exclude: infer E extends Filter } // MUST BE LAST ? HostnameInfo extends FilterReturnTy ? HostnameInfo : Exclude> : HostnameInfo const nonLocalFilter = { exclude: { kind: ['localhost', 'link-local'] as ('localhost' | 'link-local')[], }, } as const const publicFilter = { visibility: 'public', } as const type Formats = 'hostname-info' | 'urlstring' | 'url' type FormatReturnTy< F extends Filter, Format extends Formats, > = Format extends 'hostname-info' ? FilterReturnTy | FormatReturnTy> : Format extends 'url' ? URL | FormatReturnTy> : Format extends 'urlstring' ? UrlString | FormatReturnTy> : never export type Filled = { hostnames: HostnameInfo[] toUrls: (h: HostnameInfo) => { url: UrlString | null sslUrl: UrlString | null } format: ( format?: Format, ) => FormatReturnTy<{}, Format>[] filter: ( filter: NewFilter, ) => Filled nonLocal: Filled public: Filled } export type FilledAddressInfo = AddressInfo & Filled export type ServiceInterfaceFilled = { id: string /** The title of this field to be displayed */ name: string /** Human readable description, used as tooltip usually */ description: string /** Whether or not to mask the URIs for this interface. Useful if the URIs contain sensitive information, such as a password, macaroon, or API key */ masked: boolean /** Information about the host for this binding */ host: Host | null /** URI information */ addressInfo: FilledAddressInfo | null /** Indicates if we are a ui/p2p/api for the kind of interface that this is representing */ type: ServiceInterfaceType } const either = (...args: ((a: A) => boolean)[]) => (a: A) => args.some((x) => x(a)) const negate = (fn: (a: A) => boolean) => (a: A) => !fn(a) const unique = (values: A[]) => Array.from(new Set(values)) export const addressHostToUrl = ( { scheme, sslScheme, username, suffix }: AddressInfo, hostname: HostnameInfo, ): { url: UrlString | null; sslUrl: UrlString | null } => { const res = [] const fmt = (scheme: string | null, host: HostnameInfo, port: number) => { const excludePort = scheme && scheme in knownProtocols && port === knownProtocols[scheme as keyof typeof knownProtocols].defaultPort let hostname if (host.kind === 'ip') { if (host.hostname.kind === 'domain') { hostname = host.hostname.value } else if (host.hostname.kind === 'ipv6') { hostname = IPV6_LINK_LOCAL.contains(host.hostname.value) ? `[${host.hostname.value}%${host.hostname.scopeId}]` : `[${host.hostname.value}]` } else { hostname = host.hostname.value } } return `${scheme ? `${scheme}://` : ''}${ username ? `${username}@` : '' }${hostname}${excludePort ? '' : `:${port}`}${suffix}` } let url = null if (hostname.hostname.port !== null) { url = fmt(scheme, hostname, hostname.hostname.port) } let sslUrl = null if (hostname.hostname.sslPort !== null) { sslUrl = fmt(sslScheme, hostname, hostname.hostname.sslPort) } return { url, sslUrl } } function filterRec( hostnames: HostnameInfo[], filter: Filter, invert: boolean, ): HostnameInfo[] { if (filter.predicate) { const pred = filter.predicate hostnames = hostnames.filter((h) => invert !== pred(h)) } if (filter.visibility === 'public') hostnames = hostnames.filter((h) => invert !== h.public) if (filter.visibility === 'private') hostnames = hostnames.filter((h) => invert !== !h.public) if (filter.kind) { const kind = new Set( Array.isArray(filter.kind) ? filter.kind : [filter.kind], ) if (kind.has('ip')) { kind.add('ipv4') kind.add('ipv6') } hostnames = hostnames.filter( (h) => invert !== ((kind.has('mdns') && h.kind === 'ip' && h.hostname.kind === 'local') || (kind.has('domain') && h.kind === 'ip' && h.hostname.kind === 'domain') || (kind.has('ipv4') && h.kind === 'ip' && h.hostname.kind === 'ipv4') || (kind.has('ipv6') && h.kind === 'ip' && h.hostname.kind === 'ipv6') || (kind.has('localhost') && ['localhost', '127.0.0.1', '::1'].includes(h.hostname.value)) || (kind.has('link-local') && h.kind === 'ip' && h.hostname.kind === 'ipv6' && IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname.value)))), ) } if (filter.exclude) return filterRec(hostnames, filter.exclude, !invert) return hostnames } export const filledAddress = ( host: Host, addressInfo: AddressInfo, ): FilledAddressInfo => { const toUrls = addressHostToUrl.bind(null, addressInfo) const toUrlArray = (h: HostnameInfo) => { const u = toUrls(h) return [u.url, u.sslUrl].filter((u) => u !== null) } const hostnames = host.hostnameInfo[addressInfo.internalPort] ?? [] function filledAddressFromHostnames( hostnames: HostnameInfo[], ): Filled & AddressInfo { const getNonLocal = once(() => filledAddressFromHostnames( filterRec(hostnames, nonLocalFilter, false), ), ) const getPublic = once(() => filledAddressFromHostnames( filterRec(hostnames, publicFilter, false), ), ) return { ...addressInfo, hostnames, toUrls, format: (format?: Format) => { let res: FormatReturnTy<{}, Format>[] = hostnames as any if (format === 'hostname-info') return res const urls = hostnames.flatMap(toUrlArray) if (format === 'url') res = urls.map((u) => new URL(u)) as any else res = urls as any return res }, filter: (filter: NewFilter) => { return filledAddressFromHostnames( filterRec(hostnames, filter, false), ) }, get nonLocal(): Filled { return getNonLocal() }, get public(): Filled { return getPublic() }, } } return filledAddressFromHostnames<{}>(hostnames) } const makeInterfaceFilled = async ({ effects, id, packageId, callback, }: { effects: Effects id: string packageId?: string callback?: () => void }) => { const serviceInterfaceValue = await effects.getServiceInterface({ serviceInterfaceId: id, packageId, callback, }) if (!serviceInterfaceValue) { return null } const hostId = serviceInterfaceValue.addressInfo.hostId const host = await effects.getHostInfo({ packageId, hostId, callback, }) const interfaceFilled: ServiceInterfaceFilled = { ...serviceInterfaceValue, host, addressInfo: host ? filledAddress(host, serviceInterfaceValue.addressInfo) : null, } return interfaceFilled } export class GetServiceInterface { constructor( readonly effects: Effects, readonly opts: { id: string; packageId?: string }, readonly map: (interfaces: ServiceInterfaceFilled | null) => Mapped, readonly eq: (a: Mapped, b: Mapped) => boolean, ) {} /** * Returns the requested service interface. Reruns the context from which it has been called if the underlying value changes */ async const() { let abort = new AbortController() const watch = this.watch(abort.signal) const res = await watch.next() if (this.effects.constRetry) { watch .next() .then(() => { abort.abort() this.effects.constRetry && this.effects.constRetry() }) .catch() } return res.value } /** * Returns the requested service interface. Does nothing if the value changes */ async once() { const { id, packageId } = this.opts const interfaceFilled = await makeInterfaceFilled({ effects: this.effects, id, packageId, }) return this.map(interfaceFilled) } private async *watchGen(abort?: AbortSignal) { let prev = null as { value: Mapped } | null const { id, packageId } = this.opts const resolveCell = { resolve: () => {} } this.effects.onLeaveContext(() => { resolveCell.resolve() }) abort?.addEventListener('abort', () => resolveCell.resolve()) while (this.effects.isInContext && !abort?.aborted) { let callback: () => void = () => {} const waitForNext = new Promise((resolve) => { callback = resolve resolveCell.resolve = resolve }) const next = this.map( await makeInterfaceFilled({ effects: this.effects, id, packageId, callback, }), ) if (!prev || !this.eq(prev.value, next)) { yield next } await waitForNext } return new Promise((_, rej) => rej(new Error('aborted'))) } /** * Watches the requested service interface. Returns an async iterator that yields whenever the value changes */ watch(abort?: AbortSignal): AsyncGenerator { const ctrl = new AbortController() abort?.addEventListener('abort', () => ctrl.abort()) return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) } /** * Watches the requested service interface. Takes a custom callback function to run whenever the value changes */ onChange( callback: ( value: Mapped | null, error?: Error, ) => { cancel: boolean } | Promise<{ cancel: boolean }>, ) { ;(async () => { const ctrl = new AbortController() for await (const value of this.watch(ctrl.signal)) { try { const res = await callback(value) if (res.cancel) { ctrl.abort() break } } catch (e) { console.error( 'callback function threw an error @ GetServiceInterface.onChange', e, ) } } })() .catch((e) => callback(null, e)) .catch((e) => console.error( 'callback function threw an error @ GetServiceInterface.onChange', e, ), ) } /** * Watches the requested service interface. Returns when the predicate is true */ waitFor(pred: (value: Mapped) => boolean): Promise { const ctrl = new AbortController() return DropPromise.of( Promise.resolve().then(async () => { for await (const next of this.watchGen(ctrl.signal)) { if (pred(next)) { return next } } throw new Error('context left before predicate passed') }), () => ctrl.abort(), ) } } export function getOwnServiceInterface( effects: Effects, id: ServiceInterfaceId, ): GetServiceInterface export function getOwnServiceInterface( effects: Effects, id: ServiceInterfaceId, map: (interfaces: ServiceInterfaceFilled | null) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterface export function getOwnServiceInterface( effects: Effects, id: ServiceInterfaceId, map?: (interfaces: ServiceInterfaceFilled | null) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterface { return new GetServiceInterface( effects, { id }, map ?? ((a) => a as Mapped), eq ?? ((a, b) => deepEqual(a, b)), ) } export function getServiceInterface( effects: Effects, opts: { id: ServiceInterfaceId; packageId: PackageId }, ): GetServiceInterface export function getServiceInterface( effects: Effects, opts: { id: ServiceInterfaceId; packageId: PackageId }, map: (interfaces: ServiceInterfaceFilled | null) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterface export function getServiceInterface( effects: Effects, opts: { id: ServiceInterfaceId; packageId: PackageId }, map?: (interfaces: ServiceInterfaceFilled | null) => Mapped, eq?: (a: Mapped, b: Mapped) => boolean, ): GetServiceInterface { return new GetServiceInterface( effects, opts, map ?? ((a) => a as Mapped), eq ?? ((a, b) => deepEqual(a, b)), ) }