import { AtIdentifierString, ensureValidAtIdentifier, isDidIdentifier, } from './at-identifier.js' import { AtUriString } from './aturi_validation.js' import { DidString, InvalidDidError } from './did.js' import { NsidString, ensureValidNsid } from './nsid.js' import { RecordKeyString, ensureValidRecordKey } from './recordkey.js' export * from './aturi_validation.js' // Re-export types used in public interface export type { AtIdentifierString, AtUriString, DidString, NsidString, RecordKeyString, } export const ATP_URI_REGEX = // proto- --did-------------- --name---------------- --path---- --query-- --hash-- /^(at:\/\/)?((?:did:[a-z0-9:%-]+)|(?:[a-z0-9][a-z0-9.:-]*))(\/[^?#\s]*)?(\?[^#\s]+)?(#[^\s]+)?$/i // --path----- --query-- --hash-- const RELATIVE_REGEX = /^(\/[^?#\s]*)?(\?[^#\s]+)?(#[^\s]+)?$/i export class AtUri { hash: string host: AtIdentifierString pathname: string searchParams: URLSearchParams constructor(uri: string, base?: string | AtUri) { const parsed = base !== undefined ? typeof base === 'string' ? Object.assign(parse(base), parseRelative(uri)) : Object.assign({ host: base.host }, parseRelative(uri)) : parse(uri) ensureValidAtIdentifier(parsed.host) this.hash = parsed.hash ?? '' this.host = parsed.host this.pathname = parsed.pathname ?? '' this.searchParams = parsed.searchParams } static make(handleOrDid: string, collection?: string, rkey?: string) { let str = handleOrDid if (collection) str += '/' + collection if (rkey) str += '/' + rkey return new AtUri(str) } get protocol() { return 'at:' } get origin() { return `at://${this.host}` as const } get did(): DidString { const { host } = this if (isDidIdentifier(host)) return host throw new InvalidDidError(`AtUri "${this}" does not have a DID hostname`) } get hostname(): AtIdentifierString { return this.host } set hostname(v: string) { ensureValidAtIdentifier(v) this.host = v } get search() { return this.searchParams.toString() } set search(v: string) { this.searchParams = new URLSearchParams(v) } get collection() { return this.pathname.split('/').filter(Boolean)[0] || '' } get collectionSafe(): NsidString { const { collection } = this ensureValidNsid(collection) return collection } set collection(v: string) { ensureValidNsid(v) this.unsafelySetCollection(v) } unsafelySetCollection(v: string) { const parts = this.pathname.split('/').filter(Boolean) parts[0] = v this.pathname = parts.join('/') } get rkey() { return this.pathname.split('/').filter(Boolean)[1] || '' } get rkeySafe(): RecordKeyString { const { rkey } = this ensureValidRecordKey(rkey) return rkey } set rkey(v: string) { ensureValidRecordKey(v) this.unsafelySetRkey(v) } unsafelySetRkey(v: string) { const parts = this.pathname.split('/').filter(Boolean) parts[0] ||= 'undefined' parts[1] = v this.pathname = parts.join('/') } get href() { return this.toString() } toString(): AtUriString { let pathname = this.pathname if (pathname && !pathname.startsWith('/')) { pathname = `/${pathname}` } while (pathname.endsWith('/')) { pathname = pathname.slice(0, -1) } let qs = '' if (this.searchParams.size) { qs = `?${this.searchParams.toString()}` } // @NOTE We keep the hash as-is, even if it doesn't start with a '/'. let fragment = this.hash if (fragment === '#') { fragment = '' } else if (fragment && !fragment.startsWith('#')) { fragment = `#${fragment}` } return `at://${this.host}${pathname}${qs}${fragment}` as AtUriString } } function parse(str: string) { const match = str.match(ATP_URI_REGEX) as null | { 0: string 1: string | undefined // proto 2: string // host 3: string | undefined // path 4: string | undefined // query 5: string | undefined // hash } if (!match) { throw new Error(`Invalid AT uri: ${str}`) } return { host: match[2], hash: match[5], pathname: match[3], searchParams: new URLSearchParams(match[4]), } } function parseRelative(str: string) { const match = str.match(RELATIVE_REGEX) as null | { 0: string 1: string | undefined // path 2: string | undefined // query 3: string | undefined // hash } if (!match) { throw new Error(`Invalid path: ${str}`) } return { hash: match[3], pathname: match[1], searchParams: new URLSearchParams(match[2]), } }