/** * NameSpaced Identifier class * * Validation and parsing based on the NSID specification: * https://atproto.com/specs/nsid * * @example NSIDs * ```typescript * import { NSID } from '@atp/syntax' * * const id1 = NSID.parse('com.example.foo') * id1.authority // => 'example.com' * id1.name // => 'foo' * id1.toString() // => 'com.example.foo' * * const id2 = NSID.create('example.com', 'foo') * id2.authority // => 'example.com' * id2.name // => 'foo' * id2.toString() // => 'com.example.foo' * * NSID.isValid('com.example.foo') // => true * NSID.isValid('com.example.someThing') // => true * NSID.isValid('example.com/foo') // => false * NSID.isValid('foo') // => false * ``` */ export class NSID { readonly segments: readonly string[]; static parse(input: string): NSID { return new NSID(input); } static create(authority: string, name: string): NSID { const input = [...authority.split(".").reverse(), name].join("."); return new NSID(input); } static isValid(nsid: string): boolean { return isValidNsid(nsid); } static from(input: { toString: () => string }): NSID { if (input instanceof NSID) { // No need to clone, NSID is immutable return input; } if (Array.isArray(input)) { return new NSID((input as string[]).join(".")); } return new NSID(String(input)); } constructor(nsid: string) { this.segments = parseNsid(nsid); } get authority(): string { return this.segments .slice(0, this.segments.length - 1) .reverse() .join("."); } get name(): string | undefined { return this.segments.at(this.segments.length - 1); } toString(): string { return this.segments.join("."); } } export function ensureValidNsid(nsid: string): void { const result = validateNsid(nsid); if (!result.success) throw new InvalidNsidError(result.message); } export function parseNsid(nsid: string): string[] { const result = validateNsid(nsid); if (!result.success) throw new InvalidNsidError(result.message); return result.value; } export function isValidNsid(nsid: string): boolean { // Since the regex version is more performant for valid NSIDs, we use it when // we don't care about error details. return validateNsidRegex(nsid).success; } type ValidateResult = | { success: true; value: T } | { success: false; message: string }; // Human readable constraints on NSID: // - a valid domain in reversed notation // - followed by an additional period-separated name, which is camel-case letters export function validateNsid(input: string): ValidateResult { if (input.length > 253 + 1 + 63) { return { success: false, message: "NSID is too long (317 chars max)", }; } if (hasDisallowedCharacters(input)) { return { success: false, message: "Disallowed characters in NSID (ASCII letters, digits, dashes, periods only)", }; } const segments = input.split("."); if (segments.length < 3) { return { success: false, message: "NSID needs at least three parts", }; } for (const l of segments) { if (l.length < 1) { return { success: false, message: "NSID parts can not be empty", }; } if (l.length > 63) { return { success: false, message: "NSID part too long (max 63 chars)", }; } if (startsWithHyphen(l) || endsWithHyphen(l)) { return { success: false, message: "NSID parts can not start or end with hyphen", }; } } if (startsWithNumber(segments[0])) { return { success: false, message: "NSID first part may not start with a digit", }; } if (!isValidIdentifier(segments[segments.length - 1])) { return { success: false, message: "NSID name part must be only letters and digits (and no leading digit)", }; } return { success: true, value: segments, }; } function hasDisallowedCharacters(v: string) { return !/^[a-zA-Z0-9.-]*$/.test(v); } function startsWithNumber(v: string) { const charCode = v.charCodeAt(0); return charCode >= 48 && charCode <= 57; } function startsWithHyphen(v: string) { return v.charCodeAt(0) === 45; /* - */ } function endsWithHyphen(v: string) { return v.charCodeAt(v.length - 1) === 45; /* - */ } function isValidIdentifier(v: string) { // Note, since we already know that "v" only contains [a-zA-Z0-9-], we can // simplify the following regex by checking only the first char and presence // of "-". // return /^[a-zA-Z][a-zA-Z0-9]*$/.test(v) return !startsWithNumber(v) && !v.includes("-"); } /** * @deprecated Use {@link ensureValidNsid} if you care about error details, * {@link parseNsid}/{@link NSID.parse} if you need the parsed segments, or * {@link isValidNsid} if you just want a boolean. */ export function ensureValidNsidRegex(nsid: string): void { const result = validateNsidRegex(nsid); if (!result.success) throw new InvalidNsidError(result.message); } /** * Regexp based validation that behaves identically to the previous code but * provides less detailed error messages (while being 20% to 50% faster). */ export function validateNsidRegex(value: string): ValidateResult { if (value.length > 253 + 1 + 63) { return { success: false, message: "NSID is too long (317 chars max)", }; } if ( !/^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)$/ .test( value, ) ) { return { success: false, message: "NSID didn't validate via regex", }; } return { success: true, value, }; } export class InvalidNsidError extends Error {}