Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 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
25b513c
replace refresh with upkeep in protobuf
tabcat Feb 2, 2026
d983e48
add upkeep to Publish/RepublishOptions
tabcat Feb 2, 2026
6057cc0
clean up comment
tabcat Feb 2, 2026
cd4c899
skip looking for published records if force = true
tabcat Feb 2, 2026
775d0b3
publish and republish use upkeep policy
tabcat Feb 2, 2026
a4d5cf8
republish offline
tabcat Feb 2, 2026
e2b1ff6
test republish disabled records and fix tests
tabcat Feb 2, 2026
732b74b
lint --fix
tabcat Feb 2, 2026
ab66537
test: remove redudant test
tabcat Feb 2, 2026
3a28a2b
republishing offline skips public resolution
tabcat Feb 2, 2026
8168562
test: offline republish
tabcat Feb 2, 2026
b704448
clearer comment
tabcat Feb 2, 2026
0aa5118
style and fix publish metadata
tabcat Feb 3, 2026
f6950bf
chore!: remove unused metadata interface
tabcat Feb 3, 2026
025e34d
just check if already published
tabcat Feb 3, 2026
3571286
Merge branch 'main' into feat/refresh-record
tabcat Feb 5, 2026
470ac61
look for RecordNotFoundError
tabcat Feb 5, 2026
70000c0
feat: add option for skipping resolution
tabcat Feb 8, 2026
5769d4a
helpful options doc
tabcat Feb 9, 2026
803e0ed
Merge branch 'main' into feat/refresh-record
tabcat Feb 10, 2026
7caa3f2
sequential upkeep
tabcat Apr 16, 2026
bec425b
remove mistake
tabcat Apr 16, 2026
dba407b
Merge remote-tracking branch 'origin' into feat/refresh-record
tabcat Apr 16, 2026
d7dd3bb
add tests for offline and skipResolution
tabcat Apr 16, 2026
783fa1b
use custom error for record already published
tabcat Apr 16, 2026
ea56eab
fix republish jsdoc
tabcat Apr 16, 2026
b653021
0x12
tabcat Apr 16, 2026
903bfa2
add tests for unpublish
tabcat Apr 16, 2026
c88e018
prefer format specifier for logging
tabcat Apr 16, 2026
712bfbf
better description for RepublishOptions.upkeep
tabcat Apr 16, 2026
b4eb07c
add tests for invalid record and force
tabcat Apr 16, 2026
60d733f
validate metadata before writing
tabcat Apr 16, 2026
2522e19
clarify ipns docs and test comments
tabcat Apr 18, 2026
5466d39
fix broken datastore.get assertion in republish test
tabcat Apr 18, 2026
4726136
remove stale imports from republish example
tabcat Apr 18, 2026
792e96f
clarify unpublish jsdoc about keychain scope
tabcat Apr 18, 2026
06d0414
add upkeep round-trip tests for publish and republish
tabcat Apr 18, 2026
70b9ced
attach conflicting record to RecordAlreadyPublishedError
tabcat Apr 18, 2026
1eb56e5
fix lint
tabcat Apr 19, 2026
393fd5d
warn against concurrent republishing in example
tabcat Apr 19, 2026
2f27fc4
remove validateMetadata
tabcat Apr 19, 2026
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
24 changes: 11 additions & 13 deletions packages/ipns/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,15 @@ It is sometimes useful to be able to republish an existing IPNS record
without needing the private key. This allows you to extend the availability
of a record that was created elsewhere.

There should be only one republisher per IPNS key. Multiple machines
republishing the same key will conflict on sequence numbers and flood the
DHT with redundant writes.

```TypeScript
import { createHelia } from 'helia'
import { ipns, ipnsValidator } from '@helia/ipns'
import { ipns } from '@helia/ipns'
import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client'
import { CID } from 'multiformats/cid'
import { multihashToIPNSRoutingKey, marshalIPNSRecord } from 'ipns'
import { defaultLogger } from '@libp2p/logger'

const helia = await createHelia()
Expand All @@ -193,18 +196,13 @@ const delegatedClient = delegatedRoutingV1HttpApiClient({
})
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)
// republish to routing; throws RecordAlreadyPublishedError if a newer record
// is already resolvable — pass `force: true` only if you know no one else is
// republishing this key
const { record: latestRecord } = await name.republish(parsedCid, { record })

// publish record to routing
await Promise.all(
name.routers.map(async r => {
await r.put(routingKey, marshaledRecord)
})
)
// stop republishing a key
await name.unpublish(parsedCid)
```

# Install
Expand Down
11 changes: 11 additions & 0 deletions packages/ipns/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { IPNSRecord } from 'ipns'

export class RecordsFailedValidationError extends Error {
static name = 'RecordsFailedValidationError'

Expand Down Expand Up @@ -47,3 +49,12 @@ export class RecordNotFoundError extends Error {
static name = 'RecordNotFoundError'
name = 'RecordNotFoundError'
}

export class RecordAlreadyPublishedError extends Error {
static name = 'RecordAlreadyPublishedError'
name = 'RecordAlreadyPublishedError'

constructor (message: string, public readonly record: IPNSRecord) {
super(message)
}
}
108 changes: 87 additions & 21 deletions packages/ipns/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,15 @@
* without needing the private key. This allows you to extend the availability
* of a record that was created elsewhere.
*
* There should be only one republisher per IPNS key. Multiple machines
* republishing the same key will conflict on sequence numbers and flood the
* DHT with redundant writes.
*
* ```TypeScript
* import { createHelia } from 'helia'
* import { ipns, ipnsValidator } from '@helia/ipns'
* import { ipns } from '@helia/ipns'
* import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client'
* import { CID } from 'multiformats/cid'
* import { multihashToIPNSRoutingKey, marshalIPNSRecord } from 'ipns'
* import { defaultLogger } from '@libp2p/logger'
*
* const helia = await createHelia()
Expand All @@ -164,18 +167,13 @@
* })
* 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)
* // republish to routing; throws RecordAlreadyPublishedError if a newer record
* // is already resolvable — pass `force: true` only if you know no one else is
* // republishing this key
* const { record: latestRecord } = await name.republish(parsedCid, { record })
*
* // publish record to routing
* await Promise.all(
* name.routers.map(async r => {
* await r.put(routingKey, marshaledRecord)
* })
* )
* // stop republishing a key
* await name.unpublish(parsedCid)
* ```
*/

Expand Down Expand Up @@ -206,6 +204,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 All @@ -219,7 +222,7 @@ export interface PublishOptions extends AbortOptions, ProgressOptions<PublishPro
lifetime?: number

/**
* Only publish to a local datastore (default: false)
* Only publish to the local datastore, skipping the routers (default: false)
*/
offline?: boolean

Expand All @@ -233,11 +236,15 @@ export interface PublishOptions extends AbortOptions, ProgressOptions<PublishPro
* The TTL of the record in ms (default: 5 minutes)
*/
ttl?: number
}

export interface IPNSRecordMetadata {
keyName: string
lifetime: number
/**
* Automated record upkeep policy. (default: "republish")
*
* - `republish`: create a new record with a refreshed TTL
* - `refresh`: publish the existing record until it expires
* - `none`: disable automated publishing
*/
upkeep?: 'republish' | 'refresh' | 'none'
}

export interface ResolveOptions extends AbortOptions, ProgressOptions<ResolveProgressEvents | IPNSRoutingProgressEvents> {
Expand All @@ -256,6 +263,45 @@ 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

/**
* Only republish to the local datastore, skipping the routers (default: false)
*/
offline?: boolean

/**
* Skip resolution of latest record before republishing (default: false)
*
* It's important to resolve the latest record before republishing to routers
* Resolution should only be skipped when confident the latest record is already known
*/
skipResolution?: boolean

/**
* Force the record to be republished even when already resolvable (default: false)
*
* It's important for republishing to be handled by a single machine
* Republishing should only be forced when confident the record is not being republished by other clients
*/
force?: boolean

/**
* Automated record upkeep policy. (default: "refresh")
*
* Defaults to `refresh` since `republish()` cannot sign new records without
* the private key.
*
* - `refresh`: republish the existing record until it expires
* - `none`: disable automated publishing
*/
upkeep?: 'refresh' | 'none'
}

export interface ResolveResult {
/**
* The CID that was resolved
Expand Down Expand Up @@ -287,6 +333,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 @@ -346,13 +399,26 @@ export interface IPNS {
/**
* Stop republishing of an IPNS record
*
* This will delete the last signed IPNS record from the datastore, but the
* key will remain in the keychain.
* This will delete the last signed IPNS record from the datastore. If a key
* name is passed, the key will remain in the keychain.
*
* 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
*
* Updates the record's upkeep policy to `options.upkeep` (default: 'refresh').
* The background republisher will then keep the record alive accordingly.
*
* Use `unpublish` to stop republishing a key.
*
* @throws {NotFoundError} when no existing records can be found
* @throws {RecordAlreadyPublishedError} when a record is already published; pass `force: true` to bypass
*/
republish(key: CID<unknown, 0x72, 0x00 | 0x12, 1> | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: RepublishOptions): Promise<IPNSRepublishResult>
}

export type { IPNSRouting } from './routing/index.ts'
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.ts'
import { helia } from './routing/helia.ts'
import { localStoreRouting } from './routing/local-store.ts'
import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSResolveResult, PublishOptions, ResolveOptions } from './index.ts'
import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSRepublishResult, IPNSResolveResult, PublishOptions, RepublishOptions, ResolveOptions } from './index.ts'
import type { LocalStore } from './local-store.ts'
import type { IPNSRouting } from './routing/index.ts'
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)
}
}
18 changes: 12 additions & 6 deletions packages/ipns/src/ipns/publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ 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 { Upkeep } from '../pb/metadata.ts'
import { keyToMultihash } from '../utils.ts'
import type { IPNSPublishResult, PublishOptions } from '../index.ts'
import type { LocalStore } from '../local-store.ts'
import type { IPNSRouting } from '../routing/index.ts'
Expand Down Expand Up @@ -57,13 +59,14 @@ export class IPNSPublisher {
const record = await createIPNSRecord(privKey, value, sequenceNumber, lifetime, { ...options, ttlNs })
const marshaledRecord = marshalIPNSRecord(record)

const metadata = { keyName, lifetime, upkeep: Upkeep[options.upkeep ?? 'republish'] }
if (options.offline === true) {
// only store record locally
await this.localStore.put(routingKey, marshaledRecord, { ...options, metadata: { keyName, lifetime } })
await this.localStore.put(routingKey, marshaledRecord, { ...options, metadata })
} else {
// publish record to routing (including the local store)
await Promise.all(this.routers.map(async r => {
await r.put(routingKey, marshaledRecord, { ...options, metadata: { keyName, lifetime } })
await r.put(routingKey, marshaledRecord, { ...options, metadata })
}))
}

Expand All @@ -88,10 +91,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)
}
}
Loading