diff --git a/package.json b/package.json index 9cc6971..ca6ce85 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "test:browser": "playwright-test --runner mocha 'test/**/!(*.node).test.js'" }, "dependencies": { - "@multiformats/multiaddr": "^12.4.0", + "@vascosantos/multiaddr": "^12.4.2", "multiformats": "^13.3.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b346a6d..36c10d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,9 @@ importers: .: dependencies: - '@multiformats/multiaddr': - specifier: ^12.4.0 - version: 12.4.0 + '@vascosantos/multiaddr': + specifier: ^12.4.2 + version: 12.4.2 multiformats: specifier: ^13.3.6 version: 13.3.6 @@ -348,9 +348,6 @@ packages: '@multiformats/dns@1.0.6': resolution: {integrity: sha512-nt/5UqjMPtyvkG9BQYdJ4GfLK3nMqGpFZOzf4hAmIa0sJh2LlS9YKXZ4FgwBDsaHvzZqR/rUFIywIc7pkHNNuw==} - '@multiformats/multiaddr@12.4.0': - resolution: {integrity: sha512-FL7yBTLijJ5JkO044BGb2msf+uJLrwpD6jD6TkXlbjA9N12+18HT40jvd4o5vL4LOJMc86dPX6tGtk/uI9kYKg==} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -476,6 +473,9 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vascosantos/multiaddr@12.4.2': + resolution: {integrity: sha512-0nNO3eBXtBtKKc4FgcnINHK5gdDm9j5H4TwK9dLVvfjMN+aX5UBBB58GxvV4bFlsg2JuC9895uhlJPNN88j7Eg==} + '@vue/compiler-core@3.5.14': resolution: {integrity: sha512-k7qMHMbKvoCXIxPhquKQVw3Twid3Kg4s7+oYURxLGRd56LiuHJVrvFKI4fm2AM3c8apqODPfVJGoh8nePbXMRA==} @@ -2640,15 +2640,6 @@ snapshots: progress-events: 1.0.1 uint8arrays: 5.1.0 - '@multiformats/multiaddr@12.4.0': - dependencies: - '@chainsafe/is-ip': 2.1.0 - '@chainsafe/netmask': 2.0.0 - '@multiformats/dns': 1.0.6 - multiformats: 13.3.6 - uint8-varint: 2.0.4 - uint8arrays: 5.1.0 - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2786,6 +2777,15 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vascosantos/multiaddr@12.4.2': + dependencies: + '@chainsafe/is-ip': 2.1.0 + '@chainsafe/netmask': 2.0.0 + '@multiformats/dns': 1.0.6 + multiformats: 13.3.6 + uint8-varint: 2.0.4 + uint8arrays: 5.1.0 + '@vue/compiler-core@3.5.14': dependencies: '@babel/parser': 7.27.2 diff --git a/src/api.ts b/src/api.ts index 58b7b75..f22faac 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,8 +1,9 @@ import { CID } from 'multiformats/cid' -import { Multiaddr } from '@multiformats/multiaddr' +import { Multiaddr } from '@vascosantos/multiaddr' export interface ProviderHint { multiaddr: Multiaddr + tags?: string[] } export type { CID, Multiaddr } diff --git a/src/index.js b/src/index.js index a6e22ec..ff7d813 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ import * as API from './api.js' -import { multiaddr, isMultiaddr } from '@multiformats/multiaddr' +import { multiaddr, isMultiaddr } from '@vascosantos/multiaddr' import { CID } from 'multiformats/cid' /** @@ -36,6 +36,10 @@ export function createUri({ base, path, providers = [] }) { const baseParts = provider.multiaddr.toString().split('/') const hintParts = [...baseParts] + for (const tag of provider.tags || []) { + if (tag) hintParts.push('tag', tag) + } + const hint = '/' + hintParts.filter(Boolean).join('/') url.searchParams.append('provider', hint) } @@ -45,7 +49,7 @@ export function createUri({ base, path, providers = [] }) { /** * Parse a provider-hinted URI into a CID and structured provider hints, - * preserving the full multiaddr including `/retrieval/` segments. + * preserving the full multiaddr including `/tag/` segments. * * @param {string} uri * @returns {{ cid: API.CID, providers: API.ProviderHint[], path: string, protocol: string }} @@ -79,9 +83,25 @@ export function parseQueryString(query) { for (const val of searchParams.getAll('provider')) { if (!val.startsWith('/')) continue + const protoParts = val.split('/').filter(Boolean) + /** @type {string[]} */ + const tags = [] + + for (let i = 0; i < protoParts.length; ) { + if (protoParts[i] === 'tag') { + i++ + if (i < protoParts.length) { + tags.push(protoParts[i]) + i++ + } + } else { + i++ + } + } + try { const m = multiaddr(val) - providers.push({ multiaddr: m }) + providers.push({ multiaddr: m, tags }) } catch { // Skip malformed multiaddrs continue diff --git a/test/index.test.js b/test/index.test.js index 005b334..7c9307d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,7 +1,7 @@ /* global console */ import assert from 'assert' -import { multiaddr } from '@multiformats/multiaddr' +import { multiaddr } from '@vascosantos/multiaddr' import { createUri, parseUri, parseQueryString } from '../src/index.js' @@ -30,12 +30,44 @@ describe('creates and parses provider-hinted-uris', () => { assert.deepStrictEqual(parsed.providers, []) }) + it('parses provider hint with multiple tag markers', () => { + const base = `https://example.com/ipfs/${cidStr}` + const url = new URL(base) + const appendedMultiaddr = `${maddr.toString()}/tag/http/tag/bitswap` + url.searchParams.append('provider', appendedMultiaddr) + const parsed = parseUri(url.toString()) + + assert.strictEqual(parsed.providers.length, 1) + assert.strictEqual( + parsed.providers[0].multiaddr.toString(), + appendedMultiaddr + ) + assert.deepStrictEqual(parsed.providers[0].tags, ['http', 'bitswap']) + }) + it('throws on ambiguous subdomain + path format', () => { const uri = `https://${cidStr}.ipfs.dweb.link/ipfs/${cidStr}` assert.throws(() => parseUri(uri), /ambiguous/) }) - it('creates URI with provider hints', () => { + it('creates URI with provider hints (no tags)', () => { + const base = `https://example.com/ipfs/${cidStr}` + const uri = createUri({ + base, + providers: [{ multiaddr: maddr, tags: [] }], + }) + + const parsed = parseUri(uri.toString()) + assert.strictEqual(parsed.cid.toString(), cidStr) + assert.strictEqual(parsed.providers.length, 1) + assert.strictEqual( + parsed.providers[0].multiaddr.toString(), + maddr.toString() + ) + assert.deepStrictEqual(parsed.providers[0].tags, []) + }) + + it('creates URI with provider hints (undefined tags)', () => { const base = `https://example.com/ipfs/${cidStr}` const uri = createUri({ base, @@ -49,10 +81,62 @@ describe('creates and parses provider-hinted-uris', () => { parsed.providers[0].multiaddr.toString(), maddr.toString() ) + assert.deepStrictEqual(parsed.providers[0].tags, []) + }) + + it('creates URI with provider hints (with tags)', () => { + const base = `https://example.com/ipfs/${cidStr}` + const tags = ['http', 'bitswap'] + const uri = createUri({ + base, + providers: [{ multiaddr: maddr, tags }], + }) + + const parsed = parseUri(uri.toString()) + assert.strictEqual(parsed.cid.toString(), cidStr) + assert.strictEqual(parsed.providers.length, 1) + assert.strictEqual( + parsed.providers[0].multiaddr.toString(), + `${maddr.toString()}/tag/http/tag/bitswap` + ) + assert.deepStrictEqual(parsed.providers[0].tags, tags) + }) + + it('creates URI with multiple provider hints (mixed tags)', () => { + const base = `https://example.com/ipfs/${cidStr}` + const providers = [ + { multiaddr: multiaddr('/ip4/1.2.3.4/tcp/1234/ws'), tags: ['bitswap'] }, + { multiaddr: multiaddr('/ip4/5.6.7.8/tcp/443/https'), tags: ['http'] }, + { multiaddr: multiaddr('/dnsaddr/provider.example.com'), tags: [] }, + ] + + const uri = createUri({ base, providers }) + const parsed = parseUri(uri.toString()) + + assert.strictEqual(parsed.cid.toString(), cidStr) + assert.strictEqual(parsed.providers.length, 3) + + assert.strictEqual( + parsed.providers[0].multiaddr.toString(), + `${providers[0].multiaddr}/tag/bitswap` + ) + assert.deepStrictEqual(parsed.providers[0].tags, ['bitswap']) + + assert.strictEqual( + parsed.providers[1].multiaddr.toString(), + `${providers[1].multiaddr}/tag/http` + ) + assert.deepStrictEqual(parsed.providers[1].tags, ['http']) + + assert.strictEqual( + parsed.providers[2].multiaddr.toString(), + providers[2].multiaddr.toString() + ) + assert.deepStrictEqual(parsed.providers[2].tags, []) }) - it('parses provider with trailing retrieval', () => { - const ma = '/ip4/1.2.3.4/tcp/1234/retrieval' + it('parses provider with trailing tag and no protocol', () => { + const ma = '/ip4/1.2.3.4/tcp/1234/tag' const base = `https://example.com/ipfs/${cidStr}` const url = new URL(base) url.searchParams.append('provider', ma) @@ -60,18 +144,46 @@ describe('creates and parses provider-hinted-uris', () => { assert.strictEqual(parsed.providers.length, 0) }) + it('parses tag hint with unknown protocol', () => { + const ma = '/ip4/1.2.3.4/tcp/1234/tag/magictag' + const base = `https://example.com/ipfs/${cidStr}` + const url = new URL(base) + url.searchParams.append('provider', ma) + const parsed = parseUri(url.toString()) + assert.strictEqual(parsed.providers.length, 1) + assert.deepStrictEqual(parsed.providers[0].tags, ['magictag']) + }) + it('handles multiple provider hints, some invalid', () => { const base = `https://example.com/ipfs/${cidStr}` - const appendedMultiaddr = `${maddr.toString()}` + const appendedMultiaddr = `${maddr.toString()}/tag/bitswap` const url = new URL(base) url.searchParams.append('provider', appendedMultiaddr) url.searchParams.append('provider', 'bad-ma') + const appendedMultiaddr2 = '/ip4/5.6.7.8/tcp/4321/https/tag/http' + url.searchParams.append('provider', appendedMultiaddr2) const parsed = parseUri(url.toString()) - assert.strictEqual(parsed.providers.length, 1) + assert.strictEqual(parsed.providers.length, 2) assert.strictEqual( parsed.providers[0].multiaddr.toString(), appendedMultiaddr ) + assert.deepStrictEqual(parsed.providers[0].tags, ['bitswap']) + assert.strictEqual( + parsed.providers[1].multiaddr.toString(), + appendedMultiaddr2 + ) + assert.deepStrictEqual(parsed.providers[1].tags, ['http']) + }) + + it('ignores invalid multiaddr hints during parsing', () => { + const base = `https://example.com/ipfs/${cidStr}` + const url = new URL(base) + // Add a malformed multiaddr as a provider hint + url.searchParams.append('provider', '/not/a/real/multiaddr/tag/http') + const parsed = parseUri(url.toString()) + assert.deepStrictEqual(parsed.cid.toString(), cidStr) + assert.deepStrictEqual(parsed.providers, []) }) it('ignores empty provider string', () => { @@ -113,11 +225,12 @@ describe('creates and parses provider-hinted-uris', () => { providers: [ { multiaddr: maddr, + tags: ['http', 'bitswap'], }, ], }) - const expectedProvider = `${maddr.toString()}` + const expectedProvider = `${maddr.toString()}/tag/http/tag/bitswap` const parsed = parseUri(url.toString()) assert.strictEqual(parsed.cid.toString(), cidStr) @@ -127,6 +240,7 @@ describe('creates and parses provider-hinted-uris', () => { parsed.providers[0].multiaddr.toString(), expectedProvider ) + assert.deepStrictEqual(parsed.providers[0].tags, ['http', 'bitswap']) }) it('adds leading slash if path does not start with /', () => { @@ -180,7 +294,7 @@ describe('creates and parses provider-hinted-uris', () => { createUri({ base, /** @ts-expect-error */ - providers: [{ multiaddr: 'not-a-ma', protos: [] }], + providers: [{ multiaddr: 'not-a-ma', tags: [] }], }), /multiaddr must be a Multiaddr instance/ ) @@ -190,17 +304,23 @@ describe('creates and parses provider-hinted-uris', () => { describe('parseQueryString', () => { const maddr = multiaddr('/ip4/1.2.3.4/tcp/1234') - it('parses single provider hint', () => { - const input = `?provider=${encodeURIComponent(`${maddr.toString()}`)}` + it('parses single provider hint with multiple tag markers', () => { + const input = `?provider=${encodeURIComponent( + `${maddr.toString()}/tag/http/tag/bitswap` + )}` const providers = parseQueryString(input) assert.strictEqual(providers.length, 1) - assert.strictEqual(providers[0].multiaddr.toString(), `${maddr.toString()}`) + assert.strictEqual( + providers[0].multiaddr.toString(), + `${maddr.toString()}/tag/http/tag/bitswap` + ) + assert.deepStrictEqual(providers[0].tags, ['http', 'bitswap']) }) it('parses multiple valid provider hints', () => { const hints = [ - `${maddr.toString()}`, - '/ip4/5.6.7.8/tcp/4321/https', + `${maddr.toString()}/tag/http`, + '/ip4/5.6.7.8/tcp/4321/https/tag/bitswap', '/dnsaddr/provider.example.com', ] const query = hints @@ -210,18 +330,24 @@ describe('parseQueryString', () => { assert.strictEqual(providers.length, 3) assert.strictEqual(providers[0].multiaddr.toString(), hints[0]) + assert.deepStrictEqual(providers[0].tags, ['http']) + assert.strictEqual(providers[1].multiaddr.toString(), hints[1]) + assert.deepStrictEqual(providers[1].tags, ['bitswap']) + assert.strictEqual(providers[2].multiaddr.toString(), hints[2]) + assert.deepStrictEqual(providers[2].tags, []) }) it('ignores malformed multiaddrs', () => { - const valid = `${maddr.toString()}` + const valid = `${maddr.toString()}/tag/http` const query = `provider=${encodeURIComponent( valid )}&provider=not-a-ma&provider=/gibber/ish` const providers = parseQueryString(`?${query}`) assert.strictEqual(providers.length, 1) assert.strictEqual(providers[0].multiaddr.toString(), valid) + assert.deepStrictEqual(providers[0].tags, ['http']) }) it('ignores provider values not starting with "/"', () => { @@ -232,16 +358,38 @@ describe('parseQueryString', () => { providers[0].multiaddr.toString(), '/ip4/1.1.1.1/tcp/4001' ) + assert.deepStrictEqual(providers[0].tags, []) }) it('ignores empty provider values', () => { - const query = 'provider=&provider=/ip4/2.2.2.2/tcp/1234' + const query = 'provider=&provider=/ip4/2.2.2.2/tcp/1234/tag/bitswap' const providers = parseQueryString(`?${query}`) assert.strictEqual(providers.length, 1) assert.strictEqual( providers[0].multiaddr.toString(), - '/ip4/2.2.2.2/tcp/1234' + '/ip4/2.2.2.2/tcp/1234/tag/bitswap' ) + assert.deepStrictEqual(providers[0].tags, ['bitswap']) + }) + + it('supports unknown tag protocols', () => { + const input = `?provider=${encodeURIComponent( + `${maddr.toString()}/tag/magic` + )}` + + const providers = parseQueryString(input) + assert.strictEqual(providers.length, 1) + assert.strictEqual( + providers[0].multiaddr.toString(), + `${maddr.toString()}/tag/magic` + ) + assert.deepStrictEqual(providers[0].tags, ['magic']) + }) + + it('ignores trailing /tag without proto', () => { + const input = `?provider=${encodeURIComponent(`${maddr.toString()}/tag`)}` + const providers = parseQueryString(input) + assert.strictEqual(providers.length, 0) }) it('returns empty array if no provider params', () => { @@ -250,10 +398,11 @@ describe('parseQueryString', () => { }) it('decodes percent-encoded multiaddrs correctly', () => { - const raw = `${maddr.toString()}` + const raw = `${maddr.toString()}/tag/bitswap` const encoded = encodeURIComponent(raw) const providers = parseQueryString(`?provider=${encoded}`) assert.strictEqual(providers.length, 1) assert.strictEqual(providers[0].multiaddr.toString(), raw) + assert.deepStrictEqual(providers[0].tags, ['bitswap']) }) })