Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7d1e77d
add refresh property to metadata
tabcat Oct 24, 2025
32f5d61
conditionally refresh localStore record
tabcat Oct 24, 2025
4c2e7b1
PutOptions.metadata is Partial
tabcat Oct 24, 2025
c6d5d0a
#republish method supports refresh
tabcat Oct 25, 2025
28b48fd
use overwrite options instead of metadata.refresh
tabcat Oct 25, 2025
5fe9d7d
add refresh and unrefresh to republisher
tabcat Oct 25, 2025
a67063f
add RefreshOptions.repeat
tabcat Oct 25, 2025
fa80d54
add @default jsdoc tage to Refresh.force
tabcat Oct 25, 2025
b90b877
fix bool tag
tabcat Oct 26, 2025
1e0602e
log unable to refresh record as error
tabcat Oct 26, 2025
9286a63
move refresh records processing outside iterator
tabcat Oct 26, 2025
486373c
test refresh feature in #republish tests
tabcat Oct 26, 2025
c7f1345
wrap refresh logic in try/catch
tabcat Oct 27, 2025
f2f6ea5
refactor(ipns)!: nocache does not read cache
tabcat Oct 28, 2025
85b912f
cleanup record comments in refresh
tabcat Oct 28, 2025
ec19f54
published record via nocache
tabcat Oct 28, 2025
7108a72
shorten error message in refresh
tabcat Oct 29, 2025
3ab3052
fix putOptions.metadata
tabcat Oct 29, 2025
3142df1
add refresh tests
tabcat Oct 29, 2025
7822605
Merge branch 'main' into feat/refresh-record
tabcat Oct 29, 2025
1a5f2bc
lint --fix
tabcat Oct 29, 2025
13ace87
manual linter fixes
tabcat Oct 29, 2025
d4788ce
fix unrefresh test
tabcat Oct 29, 2025
85c9e7c
undo pointless change
tabcat Oct 30, 2025
e1a7017
spy on localStore not datastore
tabcat Oct 30, 2025
c572206
rename refresh to republish and remove unrefresh
tabcat Oct 31, 2025
7108a53
update example
tabcat Oct 31, 2025
82df957
fix resolve test
tabcat Oct 31, 2025
c4aa5fd
fix docs
tabcat Oct 31, 2025
0f493a5
fix localStore spy
tabcat Oct 31, 2025
488469d
move shouldRefresh check before recordsToRefresh.push
tabcat Oct 31, 2025
9bebca3
recordsToRefresh -> keysToRepublish
tabcat Oct 31, 2025
77ea1c9
fix linter error
tabcat Oct 31, 2025
d25aa37
remove additional hour for tolerance
tabcat Nov 4, 2025
754f515
fix lint error
tabcat Nov 4, 2025
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
18 changes: 6 additions & 12 deletions packages/ipns/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,18 +188,12 @@ const parsedCid: CID<unknown, 114, 0 | 18, 1> = CID.parse(ipnsName)
const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev')
const record = await delegatedClient.getIPNS(parsedCid)

const routingKey = multihashToIPNSRoutingKey(parsedCid.multihash)
const marshaledRecord = marshalIPNSRecord(record)

// validate that they key corresponds to the record
await ipnsValidator(routingKey, marshaledRecord)

// publish record to routing
await Promise.all(
name.routers.map(async r => {
await r.put(routingKey, marshaledRecord)
})
)
// publish the latest existing record to routing
// use `options.force` if the record is already published
const { record: latestRecord } = await name.republish(parsedCid, { record })

// stop republishing a key
await name.unpublish(parsedCid)
```

# Install
Expand Down
62 changes: 49 additions & 13 deletions packages/ipns/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,12 @@
* const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev')
* const record = await delegatedClient.getIPNS(parsedCid)
*
* const routingKey = multihashToIPNSRoutingKey(parsedCid.multihash)
* const marshaledRecord = marshalIPNSRecord(record)
*
* // validate that they key corresponds to the record
* await ipnsValidator(routingKey, marshaledRecord)
*
* // publish record to routing
* await Promise.all(
* name.routers.map(async r => {
* await r.put(routingKey, marshaledRecord)
* })
* )
* // publish the latest existing record to routing
* // use `options.force` if the record is already published
* const { record: latestRecord } = await name.republish(parsedCid, { record })
*
* // stop republishing a key
* await name.unpublish(parsedCid)
* ```
*/

Expand Down Expand Up @@ -201,6 +195,11 @@ export type ResolveProgressEvents =
ProgressEvent<'ipns:resolve:success', IPNSRecord> |
ProgressEvent<'ipns:resolve:error', Error>

export type RepublishProgressEvents =
ProgressEvent<'ipns:republish:start'> |
ProgressEvent<'ipns:republish:success', IPNSRecord> |
ProgressEvent<'ipns:republish:error', Error>

export type DatastoreProgressEvents =
ProgressEvent<'ipns:routing:datastore:put'> |
ProgressEvent<'ipns:routing:datastore:get'> |
Expand Down Expand Up @@ -251,6 +250,27 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions<ResolvePro
nocache?: boolean
}

export interface RepublishOptions extends AbortOptions, ProgressOptions<RepublishProgressEvents | IPNSRoutingProgressEvents> {
/**
* A candidate IPNS record to use if no newer records are found
*/
record?: IPNSRecord

/**
* Force the record to be published immediately even if it's already resolvable
*
* @default false
*/
force?: boolean

/**
* Republish the latest existing record for the key on a regularly basis
*
* @default true
*/
repeat?: boolean
}

export interface ResolveResult {
/**
* The CID that was resolved
Expand Down Expand Up @@ -282,6 +302,13 @@ export interface IPNSPublishResult {
publicKey: PublicKey
}

export interface IPNSRepublishResult {
/**
* The published record
*/
record: IPNSRecord
}

export interface IPNSResolver {
/**
* Accepts a libp2p public key, a CID with the libp2p-key codec and either the
Expand Down Expand Up @@ -347,7 +374,16 @@ export interface IPNS {
* Note that the record may still be resolved by other peers until it expires
* or is no longer valid.
*/
unpublish(keyName: string, options?: AbortOptions): Promise<void>
unpublish(keyName: string | CID<unknown, 0x72, 0x00 | 0x12, 1> | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise<void>

/**
* Republish the latest known existing record to all routers
*
* This will automatically be done regularly unless `options.repeat` is false
*
* Use `unpublish` to stop republishing a key
*/
republish(key: CID<unknown, 0x72, 0x00 | 0x12, 1> | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: RepublishOptions): Promise<IPNSRepublishResult>
}

export type { IPNSRouting } from './routing/index.js'
Expand Down
13 changes: 9 additions & 4 deletions packages/ipns/src/ipns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { IPNSResolver } from './ipns/resolver.ts'
import { localStore } from './local-store.js'
import { helia } from './routing/helia.js'
import { localStoreRouting } from './routing/local-store.ts'
import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSResolveResult, PublishOptions, ResolveOptions } from './index.js'
import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSRepublishResult, IPNSResolveResult, PublishOptions, RepublishOptions, ResolveOptions } from './index.js'
import type { LocalStore } from './local-store.js'
import type { IPNSRouting } from './routing/index.js'
import type { AbortOptions, PeerId, PublicKey, Startable } from '@libp2p/interface'
Expand Down Expand Up @@ -34,13 +34,14 @@ export class IPNS implements IPNSInterface, Startable {
routers: this.routers,
localStore: this.localStore
})
this.republisher = new IPNSRepublisher(components, {
this.resolver = new IPNSResolver(components, {
...init,
routers: this.routers,
localStore: this.localStore
})
this.resolver = new IPNSResolver(components, {
this.republisher = new IPNSRepublisher(components, {
...init,
resolver: this.resolver,
routers: this.routers,
localStore: this.localStore
})
Expand Down Expand Up @@ -81,7 +82,11 @@ export class IPNS implements IPNSInterface, Startable {
return this.resolver.resolve(key, options)
}

async unpublish (keyName: string, options?: AbortOptions): Promise<void> {
async unpublish (keyName: string | CID<unknown, 0x72, 0x00 | 0x12, 1> | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise<void> {
return this.publisher.unpublish(keyName, options)
}

async republish (key: CID<unknown, 0x72, 0x00 | 0x12, 1> | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RepublishOptions = {}): Promise<IPNSRepublishResult> {
return this.republisher.republish(key, options)
}
}
12 changes: 8 additions & 4 deletions packages/ipns/src/ipns/publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarsh
import { CID } from 'multiformats/cid'
import { CustomProgressEvent } from 'progress-events'
import { DEFAULT_LIFETIME_MS, DEFAULT_TTL_NS } from '../constants.ts'
import { keyToMultihash } from '../utils.ts'
import type { IPNSPublishResult, PublishOptions } from '../index.js'
import type { LocalStore } from '../local-store.js'
import type { IPNSRouting } from '../routing/index.js'
Expand Down Expand Up @@ -88,10 +89,13 @@ export class IPNSPublisher {
}
}

async unpublish (keyName: string, options?: AbortOptions): Promise<void> {
const { publicKey } = await this.keychain.exportKey(keyName)
const digest = publicKey.toMultihash()
const routingKey = multihashToIPNSRoutingKey(digest)
async unpublish (keyName: string | CID<unknown, 0x72, 0x00 | 0x12, 1> | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: AbortOptions): Promise<void> {
if (typeof keyName === 'string') {
const { publicKey } = await this.keychain.exportKey(keyName)
keyName = publicKey.toMultihash()
}

const routingKey = multihashToIPNSRoutingKey(keyToMultihash(keyName))
await this.localStore.delete(routingKey, options)
}
}
111 changes: 108 additions & 3 deletions packages/ipns/src/ipns/republisher.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { NotFoundError } from '@libp2p/interface'
import { Queue, repeatingTask } from '@libp2p/utils'
import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord } from 'ipns'
import { createIPNSRecord, marshalIPNSRecord, multihashFromIPNSRoutingKey, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns'
import { ipnsValidator } from 'ipns/validator'
import { CustomProgressEvent } from 'progress-events'
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts'
import { shouldRepublish } from '../utils.js'
import { ipnsSelector } from '../index.ts'
import { keyToMultihash, shouldRefresh, shouldRepublish } from '../utils.js'
import type { IPNSRepublishResult, RepublishOptions } from '../index.ts'
import type { LocalStore } from '../local-store.js'
import type { IPNSResolver } from './resolver.ts'
import type { IPNSRouting } from '../routing/index.js'
import type { AbortOptions, ComponentLogger, Libp2p, Logger, PrivateKey } from '@libp2p/interface'
import type { AbortOptions, ComponentLogger, Libp2p, Logger, PeerId, PrivateKey, PublicKey } from '@libp2p/interface'
import type { Keychain } from '@libp2p/keychain'
import type { RepeatingTask } from '@libp2p/utils'
import type { IPNSRecord } from 'ipns'
import type { CID, MultihashDigest } from 'multiformats/cid'

export interface IPNSRepublisherComponents {
logger: ComponentLogger
Expand All @@ -17,13 +25,15 @@ export interface IPNSRepublisherComponents {
export interface IPNSRepublisherInit {
republishConcurrency?: number
republishInterval?: number
resolver: IPNSResolver
routers: IPNSRouting[]
localStore: LocalStore
}

export class IPNSRepublisher {
public readonly routers: IPNSRouting[]
private readonly localStore: LocalStore
private readonly resolver: IPNSResolver
private readonly republishTask: RepeatingTask
private readonly log: Logger
private readonly keychain: Keychain
Expand All @@ -33,6 +43,7 @@ export class IPNSRepublisher {
constructor (components: IPNSRepublisherComponents, init: IPNSRepublisherInit) {
this.log = components.logger.forComponent('helia:ipns')
this.localStore = init.localStore
this.resolver = init.resolver
this.keychain = components.libp2p.services.keychain
this.republishConcurrency = init.republishConcurrency || DEFAULT_REPUBLISH_CONCURRENCY
this.started = components.libp2p.status === 'started'
Expand Down Expand Up @@ -78,6 +89,7 @@ export class IPNSRepublisher {

try {
const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord }> = []
const keysToRepublish: Array<Uint8Array> = []

// Find all records using the localStore.list method
for await (const { routingKey, record, metadata, created } of this.localStore.list(options)) {
Expand All @@ -87,6 +99,16 @@ export class IPNSRepublisher {
this.log(`no metadata found for record ${routingKey.toString()}, skipping`)
continue
}

if (metadata.refresh) {
if (!shouldRefresh(created)) {
this.log.trace(`skipping record ${routingKey.toString()} within republish threshold`)
continue
}
keysToRepublish.push(routingKey)
continue
}

let ipnsRecord: IPNSRecord
try {
ipnsRecord = unmarshalIPNSRecord(record)
Expand Down Expand Up @@ -135,10 +157,93 @@ export class IPNSRepublisher {
}
}, options)
}
for (const routingKey of keysToRepublish) {
// resolve the latest record
let latestRecord: IPNSRecord
try {
const { record } = await this.resolver.resolve(multihashFromIPNSRoutingKey(routingKey))
latestRecord = record
} catch (err: any) {
this.log.error('unable to find existing record to republish - %e', err)
continue
}

// Add job to queue to republish the existing record to all routers
queue.add(async () => {
try {
await Promise.all(
this.routers.map(r => r.put(routingKey, marshalIPNSRecord(latestRecord), { ...options, overwrite: true }))
)
} catch (err: any) {
this.log.error('error republishing existing record - %e', err)
}
}, options)
}
} catch (err: any) {
this.log.error('error during republish - %e', err)
}

await queue.onIdle(options) // Wait for all jobs to complete
}

async republish (key: CID<unknown, 0x72, 0x00 | 0x12, 1> | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: RepublishOptions = {}): Promise<IPNSRepublishResult> {
const records: IPNSRecord[] = []
let publishedRecord: IPNSRecord | null = null
const digest = keyToMultihash(key)
const routingKey = multihashToIPNSRoutingKey(digest)

try {
// collect records for key
if (options.record != null) {
// user supplied record
await ipnsValidator(routingKey, marshalIPNSRecord(options.record))
records.push(options.record)
}
try {
// local record
const { record } = await this.resolver.resolve(key, { offline: true })
records.push(record)
} catch (err: any) {
if (err.name !== 'NotFoundError') {
throw err
}
}
try {
// published record
const { record } = await this.resolver.resolve(key, { nocache: true })
publishedRecord = record
records.push(record)
} catch (err: any) {
if (err.name !== 'NotFoundError') {
throw err
}
}
if (records.length === 0) {
throw new NotFoundError('Found no existing records to republish')
}

// check if record is already published
const selectedRecord = records[ipnsSelector(routingKey, records.map(marshalIPNSRecord))]
const marshaledRecord = marshalIPNSRecord(selectedRecord)
if (options.force !== true && publishedRecord != null && uint8ArrayEquals(marshaledRecord, marshalIPNSRecord(publishedRecord))) {
throw new Error('Record already published')
}

// publish record to routers
const putOptions = {
...options,
metadata: options.repeat !== false ? { refresh: true } : undefined,
// overwrite so Record.created is reset for #republish
overwrite: true
}
await Promise.all(
this.routers.map(r => r.put(routingKey, marshaledRecord, putOptions))
)

return { record: selectedRecord }
} catch (err: any) {
options.onProgress?.(new CustomProgressEvent<Error>('ipns:republish:error', err))
throw err
}
}
}
6 changes: 6 additions & 0 deletions packages/ipns/src/ipns/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CID } from 'multiformats/cid'
import * as Digest from 'multiformats/hashes/digest'
import { DEFAULT_TTL_NS } from '../constants.ts'
import { InvalidValueError, RecordsFailedValidationError, UnsupportedMultibasePrefixError, UnsupportedMultihashCodecError } from '../errors.js'
import { LocalStoreRouting } from '../routing/local-store.ts'
import { isCodec, IDENTITY_CODEC, SHA2_256_CODEC, isLibp2pCID } from '../utils.js'
import type { IPNSResolveResult, ResolveOptions, ResolveResult } from '../index.js'
import type { LocalStore } from '../local-store.js'
Expand Down Expand Up @@ -177,6 +178,11 @@ export class IPNSResolver {
this.routers.map(async (router) => {
let record: Uint8Array

// skip checking cache when nocache is true
if (router instanceof LocalStoreRouting && options.nocache === true) {
return
}

try {
record = await router.get(routingKey, {
...options,
Expand Down
Loading