WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

refactor: consolidate AT-URI parsing into shared utility (#19)

* refactor: consolidate AT-URI parsing into shared utility

Extract duplicate AT-URI parsing logic into a single shared
parseAtUri() function in lib/at-uri.ts. Replace the Indexer's private
method and helpers' inline regex with imports of the shared utility.
Add comprehensive unit tests covering valid URIs, invalid inputs,
and edge cases.

* fix: restore observability logging in parseAtUri

- Add console.warn() for invalid AT URI format (aids firehose debugging)
- Add try-catch wrapper with structured error logging for unexpected failures
- Addresses PR #19 review feedback on observability regression

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by

Malpercio
Claude
and committed by
GitHub
ab0f6235 97864513

+207 -32
+167
apps/appview/src/lib/__tests__/at-uri.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { parseAtUri } from "../at-uri.js"; 3 + 4 + describe("parseAtUri", () => { 5 + describe("valid URIs", () => { 6 + it("parses a standard post URI", () => { 7 + const result = parseAtUri( 8 + "at://did:plc:abc123/space.atbb.post/3lbk7xxx" 9 + ); 10 + 11 + expect(result).toEqual({ 12 + did: "did:plc:abc123", 13 + collection: "space.atbb.post", 14 + rkey: "3lbk7xxx", 15 + }); 16 + }); 17 + 18 + it("parses a forum URI with 'self' rkey", () => { 19 + const result = parseAtUri( 20 + "at://did:plc:forum/space.atbb.forum.forum/self" 21 + ); 22 + 23 + expect(result).toEqual({ 24 + did: "did:plc:forum", 25 + collection: "space.atbb.forum.forum", 26 + rkey: "self", 27 + }); 28 + }); 29 + 30 + it("parses a category URI", () => { 31 + const result = parseAtUri( 32 + "at://did:plc:forum/space.atbb.forum.category/cat1" 33 + ); 34 + 35 + expect(result).toEqual({ 36 + did: "did:plc:forum", 37 + collection: "space.atbb.forum.category", 38 + rkey: "cat1", 39 + }); 40 + }); 41 + 42 + it("parses a membership URI", () => { 43 + const result = parseAtUri( 44 + "at://did:plc:user123/space.atbb.membership/mem1" 45 + ); 46 + 47 + expect(result).toEqual({ 48 + did: "did:plc:user123", 49 + collection: "space.atbb.membership", 50 + rkey: "mem1", 51 + }); 52 + }); 53 + 54 + it("parses a did:web DID", () => { 55 + const result = parseAtUri( 56 + "at://did:web:example.com/space.atbb.post/abc" 57 + ); 58 + 59 + expect(result).toEqual({ 60 + did: "did:web:example.com", 61 + collection: "space.atbb.post", 62 + rkey: "abc", 63 + }); 64 + }); 65 + 66 + it("parses a Bluesky post URI", () => { 67 + const result = parseAtUri( 68 + "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3lbk7abc" 69 + ); 70 + 71 + expect(result).toEqual({ 72 + did: "did:plc:z72i7hdynmk6r22z27h6tvur", 73 + collection: "app.bsky.feed.post", 74 + rkey: "3lbk7abc", 75 + }); 76 + }); 77 + 78 + it("handles rkey with special characters", () => { 79 + const result = parseAtUri( 80 + "at://did:plc:abc/space.atbb.post/rkey-with.dots_and-dashes" 81 + ); 82 + 83 + expect(result).toEqual({ 84 + did: "did:plc:abc", 85 + collection: "space.atbb.post", 86 + rkey: "rkey-with.dots_and-dashes", 87 + }); 88 + }); 89 + }); 90 + 91 + describe("invalid URIs", () => { 92 + it("returns null for empty string", () => { 93 + expect(parseAtUri("")).toBeNull(); 94 + }); 95 + 96 + it("returns null for a plain string", () => { 97 + expect(parseAtUri("not-a-uri")).toBeNull(); 98 + }); 99 + 100 + it("returns null for an HTTP URL", () => { 101 + expect(parseAtUri("https://example.com/path")).toBeNull(); 102 + }); 103 + 104 + it("returns null for a URI missing the rkey", () => { 105 + expect( 106 + parseAtUri("at://did:plc:abc/space.atbb.post") 107 + ).toBeNull(); 108 + }); 109 + 110 + it("returns null for a URI missing collection and rkey", () => { 111 + expect(parseAtUri("at://did:plc:abc")).toBeNull(); 112 + }); 113 + 114 + it("returns null for a URI with only the scheme", () => { 115 + expect(parseAtUri("at://")).toBeNull(); 116 + }); 117 + 118 + it("returns null for a URI with wrong scheme", () => { 119 + expect( 120 + parseAtUri("http://did:plc:abc/space.atbb.post/rkey") 121 + ).toBeNull(); 122 + }); 123 + }); 124 + 125 + describe("edge cases", () => { 126 + it("returns all three components as strings", () => { 127 + const result = parseAtUri( 128 + "at://did:plc:test/space.atbb.post/abc123" 129 + ); 130 + 131 + expect(result).not.toBeNull(); 132 + expect(typeof result!.did).toBe("string"); 133 + expect(typeof result!.collection).toBe("string"); 134 + expect(typeof result!.rkey).toBe("string"); 135 + }); 136 + 137 + it("does not match URIs with trailing slash", () => { 138 + // The regex uses (.+)$ for rkey, so trailing slash is included in rkey 139 + const result = parseAtUri( 140 + "at://did:plc:abc/space.atbb.post/rkey/" 141 + ); 142 + 143 + // rkey would be "rkey/" which includes the trailing slash 144 + expect(result).toEqual({ 145 + did: "did:plc:abc", 146 + collection: "space.atbb.post", 147 + rkey: "rkey/", 148 + }); 149 + }); 150 + 151 + it("does not match a URI with empty path segments", () => { 152 + expect(parseAtUri("at:////")).toBeNull(); 153 + }); 154 + 155 + it("handles numeric rkey (TID format)", () => { 156 + const result = parseAtUri( 157 + "at://did:plc:abc/space.atbb.post/3lbk7xyzhij2q" 158 + ); 159 + 160 + expect(result).toEqual({ 161 + did: "did:plc:abc", 162 + collection: "space.atbb.post", 163 + rkey: "3lbk7xyzhij2q", 164 + }); 165 + }); 166 + }); 167 + });
+33
apps/appview/src/lib/at-uri.ts
··· 1 + /** 2 + * Parsed components of an AT Protocol URI. 3 + * Format: at://did:plc:xxx/collection.name/rkey 4 + */ 5 + export interface ParsedAtUri { 6 + did: string; 7 + collection: string; 8 + rkey: string; 9 + } 10 + 11 + /** 12 + * Parse an AT Protocol URI into its component parts. 13 + * 14 + * @param uri - AT-URI like "at://did:plc:abc/space.atbb.post/3lbk7xxx" 15 + * @returns Parsed components, or null if the URI is invalid 16 + */ 17 + export function parseAtUri(uri: string): ParsedAtUri | null { 18 + try { 19 + const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 20 + if (!match) { 21 + console.warn(`Invalid AT URI format: ${uri}`); 22 + return null; 23 + } 24 + 25 + const [, did, collection, rkey] = match; 26 + return { did, collection, rkey }; 27 + } catch (error) { 28 + console.error(`Unexpected error parsing AT URI: ${uri}`, { 29 + error: error instanceof Error ? error.message : String(error), 30 + }); 31 + return null; 32 + } 33 + }
+3 -28
apps/appview/src/lib/indexer.ts
··· 13 13 modActions, 14 14 } from "@atbb/db"; 15 15 import { eq, and } from "drizzle-orm"; 16 + import { parseAtUri } from "./at-uri.js"; 16 17 import * as Post from "@atbb/lexicon/dist/types/types/space/atbb/post.js"; 17 18 import * as Forum from "@atbb/lexicon/dist/types/types/space/atbb/forum/forum.js"; 18 19 import * as Category from "@atbb/lexicon/dist/types/types/space/atbb/forum/category.js"; ··· 28 29 constructor(private db: Database) {} 29 30 30 31 /** 31 - * Parse an AT Proto URI to extract DID, collection, and rkey 32 - * Format: at://did:plc:xxx/collection.name/rkey 33 - */ 34 - private parseAtUri(uri: string): { 35 - did: string; 36 - collection: string; 37 - rkey: string; 38 - } | null { 39 - try { 40 - // AT Protocol URIs use at:// scheme which isn't recognized by URL constructor 41 - // Pattern: at://did:plc:xxx/space.atbb.post/rkey123 42 - const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 43 - if (!match) { 44 - console.error(`Invalid AT URI format: ${uri}`); 45 - return null; 46 - } 47 - 48 - const [, did, collection, rkey] = match; 49 - return { did, collection, rkey }; 50 - } catch (error) { 51 - console.error(`Failed to parse AT URI: ${uri}`, error); 52 - return null; 53 - } 54 - } 55 - 56 - /** 57 32 * Ensure a user exists in the database. Creates if not exists. 58 33 * @param dbOrTx - Database instance or transaction 59 34 */ ··· 83 58 forumUri: string, 84 59 dbOrTx: DbOrTransaction = this.db 85 60 ): Promise<bigint | null> { 86 - const parsed = this.parseAtUri(forumUri); 61 + const parsed = parseAtUri(forumUri); 87 62 if (!parsed) return null; 88 63 89 64 const result = await dbOrTx ··· 126 101 postUri: string, 127 102 dbOrTx: DbOrTransaction = this.db 128 103 ): Promise<bigint | null> { 129 - const parsed = this.parseAtUri(postUri); 104 + const parsed = parseAtUri(postUri); 130 105 if (!parsed) return null; 131 106 132 107 const result = await dbOrTx
+4 -4
apps/appview/src/routes/helpers.ts
··· 2 2 import type { Database } from "@atbb/db"; 3 3 import { eq, and, inArray } from "drizzle-orm"; 4 4 import { UnicodeString } from "@atproto/api"; 5 + import { parseAtUri } from "../lib/at-uri.js"; 5 6 6 7 /** 7 8 * Parse a route parameter as BigInt. ··· 106 107 db: Database, 107 108 uri: string 108 109 ): Promise<{ did: string; rkey: string; cid: string } | null> { 109 - // Parse AT-URI: at://did/collection/rkey 110 - const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/); 111 - if (!match) { 110 + const parsed = parseAtUri(uri); 111 + if (!parsed) { 112 112 return null; 113 113 } 114 114 115 - const [, did, rkey] = match; 115 + const { did, rkey } = parsed; 116 116 117 117 try { 118 118 const [forum] = await db