import { renderEmbedPage, renderOEmbedJson, renderProjectPage, type ProjectCardData } from './cards.ts'; import type { RecentOptions, TrendingOptions } from './index/model.ts'; import { ownedRecordUri } from './index/backfill.ts'; import { parseActorIdentifier } from '@nibling/sdk/identifier'; import { clientMetadata } from '@nibling/sdk/metadata'; import { isTid } from '@atcute/lexicons/syntax'; import { FETCH_TIMEOUT_MS, isPublicHttpsUrl } from './net.ts'; import type { Upstream } from './upstream.ts'; import { handleGateway, preflight, type ResponseCache } from './gateway/index.ts'; /** Caps a slug listing at 10 PDS pages of 100 records, same shape as the backfill caps in * `backfill.ts`, so a server handing out endless cursors can't hold a request open forever. */ const MAX_SLUG_PAGES = 10; /** What the router needs from the Index DO, kept to just the RPC surface it calls. Isolating * this (rather than importing `cloudflare:workers` types here) is what lets this file run under * plain `node --test`: the real binding satisfies this, and tests pass a fake. */ export interface IndexStub { getRecent(options: RecentOptions): Promise<{ projects: unknown[]; cursor?: string }>; getTrending(options: Omit): Promise<{ projects: unknown[]; cursor?: string }>; getKnown(dids: string[]): Promise; } export interface RouterEnv { INDEX: { getByName(name: string): IndexStub }; APP_ORIGIN: string; SLINGSHOT_URL: string; CONSTELLATION_URL: string; /** Every outbound request goes through this. */ fetch: Upstream; /** `caches.default` in the worker. */ cache: ResponseCache; /** Keeps a background cache write or refresh alive after the response is sent. */ waitUntil(promise: Promise): void; now?: () => number; } const XRPC_HEADERS = { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=30', }; /** `dev.nibling.actor.getKnown`'s `dids` has `maxLength: 100` in the lexicon. */ const MAX_KNOWN_DIDS = 100; function xrpcJson(body: unknown): Response { return new Response(JSON.stringify(body), { headers: XRPC_HEADERS }); } function xrpcError(error: string, message: string, status: number): Response { return new Response(JSON.stringify({ error, message }), { status, headers: XRPC_HEADERS }); } function parseLimit(raw: string | null, fallback = 30, max = 100): number { if (!raw) return fallback; const n = Number.parseInt(raw, 10); if (!Number.isFinite(n) || n < 1) return fallback; return Math.min(n, max); } /** One Slingshot query, or `undefined` if it errors, times out, or isn't JSON. */ async function slingshot(fetchFn: Upstream, slingshotUrl: string, nsid: string, params: Record): Promise | undefined> { const url = new URL(`/xrpc/${nsid}`, slingshotUrl); for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); try { const res = await fetchFn(url.toString(), { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) return undefined; const body: unknown = await res.json(); return typeof body === 'object' && body !== null ? (body as Record) : undefined; } catch { return undefined; } } /** `blue.microcosm.identity.resolveMiniDoc`: handle or DID in, `{did, pds}` out. */ async function resolveMiniDoc(fetchFn: Upstream, slingshotUrl: string, actor: string): Promise<{ did: string; pds: string } | undefined> { const body = await slingshot(fetchFn, slingshotUrl, 'blue.microcosm.identity.resolveMiniDoc', { identifier: actor }); if (typeof body?.did !== 'string' || typeof body.pds !== 'string') return undefined; return { did: body.did, pds: body.pds }; } /** `blue.microcosm.repo.getRecordByUri`. */ async function getRecordByUri(fetchFn: Upstream, slingshotUrl: string, uri: string): Promise<{ cid: string; value: Record } | undefined> { const body = await slingshot(fetchFn, slingshotUrl, 'blue.microcosm.repo.getRecordByUri', { at_uri: uri }); if (typeof body?.cid !== 'string' || typeof body.value !== 'object' || body.value === null) return undefined; return { cid: body.cid, value: body.value as Record }; } /** `com.atproto.repo.listRecords` for `dev.nibling.project` on `did`'s own PDS, scanning for the * record whose `value.slug` matches `name`. Several records can claim the same slug; the one * with the smallest (oldest) rkey wins. Paged, capped at `MAX_SLUG_PAGES`, and abandoned (keeping * whatever was already found) on a bad page, an error, or a repeated cursor. */ async function findProjectBySlug( env: RouterEnv, did: string, pds: string, name: string, ): Promise<{ rkey: string; cid: string; value: Record } | undefined> { if (!isPublicHttpsUrl(pds)) return undefined; let best: { rkey: string; cid: string; value: Record } | undefined; const seenCursors = new Set(); let cursor: string | undefined; for (let page = 0; page < MAX_SLUG_PAGES; page++) { const url = new URL('/xrpc/com.atproto.repo.listRecords', pds); url.searchParams.set('repo', did); url.searchParams.set('collection', 'dev.nibling.project'); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); let body: { records?: unknown; cursor?: unknown } | undefined; try { const res = await env.fetch(url.toString(), { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) return best; body = (await res.json()) as typeof body; } catch { return best; } if (!body || !Array.isArray(body.records)) return best; for (const record of body.records as { uri?: unknown; cid?: unknown; value?: unknown }[]) { const uri = ownedRecordUri(record?.uri, did, 'dev.nibling.project'); if (!uri || typeof record.cid !== 'string' || typeof record.value !== 'object' || record.value === null) continue; const value = record.value as Record; if (value.slug !== name) continue; const rkey = uri.slice(uri.lastIndexOf('/') + 1); if (!best || rkey < best.rkey) best = { rkey, cid: record.cid, value }; } const nextCursor = typeof body.cursor === 'string' && body.cursor !== '' ? body.cursor : undefined; if (!nextCursor || seenCursors.has(nextCursor)) return best; seenCursors.add(nextCursor); cursor = nextCursor; } return best; } /** Resolves `` the way the app does: a TID reads the record at that rkey directly; a * non-TID, or a TID that the direct read can't find, falls back to scanning the author's PDS for * the project whose `slug` matches. */ async function resolveProjectRecord( env: RouterEnv, actor: string, name: string, ): Promise<{ did: string; pds: string; rkey: string; value: Record } | undefined> { const identity = await resolveMiniDoc(env.fetch, env.SLINGSHOT_URL, actor); if (!identity) return undefined; if (isTid(name)) { const uri = `at://${identity.did}/dev.nibling.project/${name}`; const record = await getRecordByUri(env.fetch, env.SLINGSHOT_URL, uri); if (record) return { did: identity.did, pds: identity.pds, rkey: name, value: record.value }; } const bySlug = await findProjectBySlug(env, identity.did, identity.pds, name); if (!bySlug) return undefined; return { did: identity.did, pds: identity.pds, rkey: bySlug.rkey, value: bySlug.value }; } async function loadProjectCard(env: RouterEnv, actor: string, name: string): Promise { const resolved = await resolveProjectRecord(env, actor, name); if (!resolved) return undefined; const { did, pds, rkey, value } = resolved; const cardName = typeof value.name === 'string' ? value.name : rkey; const slug = typeof value.slug === 'string' ? value.slug : undefined; const description = typeof value.description === 'string' ? value.description : undefined; const thumbnail = value.thumbnail as { ref?: { $link?: string } } | undefined; const thumbnailUrl = thumbnail?.ref?.$link && isPublicHttpsUrl(pds) ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${thumbnail.ref.$link}` : undefined; const card: ProjectCardData = { actor, did, rkey, name: cardName }; if (slug !== undefined) card.slug = slug; if (description !== undefined) card.description = description; if (thumbnailUrl !== undefined) card.thumbnailUrl = thumbnailUrl; return card; } /** * `//` or `///embed`, where `` is a handle or a DID, so an unrelated path * (a route with no `.` or `:`, a 404) doesn't get mistaken for a project page. `at` says the path * spelled `` with a leading `@`, which the app no longer uses. A trailing slash is optional. */ function matchCardPath(pathname: string): { actor: string; name: string; embed: boolean; at: boolean } | undefined { const path = pathname.length > 1 ? pathname.replace(/\/$/, '') : pathname; const atUri = matchAtUriCardPath(path); if (atUri) return atUri; const match = /^\/(@?)([^/]+)\/([^/]+)(\/embed)?$/.exec(path); if (!match || !parseActorIdentifier(match[2]!)) return undefined; return { actor: match[2]!, name: match[3]!, embed: match[4] !== undefined, at: match[1] === '@' }; } /** * `/at:///dev.nibling.project/`, the single-slash `/at://…` a layer that * collapses `//` hands us instead, or that whole shape percent-encoded (slashes encoded too), the * same forms the app's own route parser accepts. There is no `@` form and no redirect for it. */ function matchAtUriCardPath(path: string): { actor: string; name: string; embed: boolean; at: boolean } | undefined { let p = path; if (/^\/at%3a/i.test(p)) { try { p = decodeURIComponent(p); } catch { return undefined; } } const match = /^\/at:\/{1,2}([^/]+)\/dev\.nibling\.project\/([^/]+)(\/embed)?$/i.exec(p); if (!match || !parseActorIdentifier(match[1]!)) return undefined; return { actor: match[1]!, name: match[2]!, embed: match[3] !== undefined, at: false }; } export async function handleRequest(request: Request, env: RouterEnv): Promise { const url = new URL(request.url); if (request.method === 'OPTIONS') return preflight(); const gateway = await handleGateway(request, { fetch: env.fetch, cache: env.cache, waitUntil: env.waitUntil, now: env.now ?? Date.now, origin: url.origin, slingshot: env.SLINGSHOT_URL, constellation: env.CONSTELLATION_URL, }); if (gateway) return gateway; const stub = env.INDEX.getByName('global'); if (url.pathname === '/xrpc/dev.nibling.feed.getRecent') { const options: RecentOptions = { limit: parseLimit(url.searchParams.get('limit')) }; const cursor = url.searchParams.get('cursor'); const tag = url.searchParams.get('tag'); if (cursor) options.cursor = cursor; if (tag) options.tag = tag; return xrpcJson(await stub.getRecent(options)); } if (url.pathname === '/xrpc/dev.nibling.feed.getTrending') { const options: Omit = { limit: parseLimit(url.searchParams.get('limit')) }; const cursor = url.searchParams.get('cursor'); if (cursor) options.cursor = cursor; return xrpcJson(await stub.getTrending(options)); } if (url.pathname === '/xrpc/dev.nibling.actor.getKnown') { const dids = url.searchParams.getAll('dids').flatMap((v) => v.split(',')); if (dids.length === 0) return xrpcError('InvalidRequest', 'dids is required', 400); if (dids.length > MAX_KNOWN_DIDS) return xrpcError('InvalidRequest', `dids accepts at most ${MAX_KNOWN_DIDS}`, 400); return xrpcJson({ dids: await stub.getKnown(dids) }); } if (url.pathname === '/oauth/client-metadata.json') { return new Response(JSON.stringify(clientMetadata({ origin: env.APP_ORIGIN, name: 'nibling.dev' })), { headers: { 'content-type': 'application/json' }, }); } const cardPath = matchCardPath(url.pathname); if (cardPath?.at) { const target = new URL(url); target.pathname = url.pathname.replace(/^\/@/, '/'); return Response.redirect(target.toString(), 301); } if (cardPath) { const card = await loadProjectCard(env, cardPath.actor, cardPath.name); if (!card) return new Response('not found', { status: 404 }); const html = cardPath.embed ? renderEmbedPage(card, env.APP_ORIGIN) : renderProjectPage(card, env.APP_ORIGIN); return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }); } if (url.pathname === '/oembed') { const pageUrl = url.searchParams.get('url'); const match = pageUrl && URL.canParse(pageUrl) ? matchCardPath(new URL(pageUrl).pathname) : undefined; if (!match) return xrpcError('InvalidRequest', 'url must point at a project page', 400); const card = await loadProjectCard(env, match.actor, match.name); if (!card) return new Response('not found', { status: 404 }); return new Response(JSON.stringify(renderOEmbedJson(card, env.APP_ORIGIN)), { headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' }, }); } return xrpcError('NotFound', 'no route for this path', 404); }