Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276import { 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<TrendingOptions, 'now'>): Promise<{ projects: unknown[]; cursor?: string }>; getKnown(dids: string[]): Promise<string[]>;}
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<unknown>): 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<string, string>): Promise<Record<string, unknown> | 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<string, unknown>) : 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<string, unknown> } | 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<string, unknown> };}
/** `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<string, unknown> } | undefined> { if (!isPublicHttpsUrl(pds)) return undefined; let best: { rkey: string; cid: string; value: Record<string, unknown> } | undefined; const seenCursors = new Set<string>(); 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<string, unknown>; 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 `<name>` 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<string, unknown> } | 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<ProjectCardData | undefined> { 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;}
/** * `/<id>/<name>` or `/<id>/<name>/embed`, where `<id>` 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 `<id>` 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://<actor>/dev.nibling.project/<rkey>`, the single-slash `/at:/<actor>/…` 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<Response> { 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<TrendingOptions, 'now'> = { 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);}