// Which archive channel keeps each post, so its QR can link the post's page // on the archive rather than bsky.app / x.com. // // A post channel is a channel of its own on the archive, with its own slug // (`piratesoftware-bsky`, not the video channel `piratesoftware`), and a manifest // rarely says which: it carries the platform's link. So the build finds it, by // the walk `/corpus.json` publishes under `postScheme`: // // 1. GET /corpus.json -> channels[] with `manifests.posts` // 2. GET each one's posts manifest -> { slugToPage: { : N } } // 3. the first channel whose slugToPage has the post's id keeps it // // Channels whose slug or name carries the post's handle are asked first, so a // post that several channels hold (a mirror, a repost) links the account that // posted it. Nothing here writes the manifest: a found channel is set on the // build's in-memory copy of the post, and deck.mjs `postQrUrl` makes the link. // A post the manifest pins (`siteChannel` or `siteUrl`) is not looked up. // // Failure is never the build's: a post not in the archive, or an archive that // does not answer, links the original, and says so in a note. // // The fetches go through cues.mjs's JSON cache -- the same files on disk the // cue walk reads -- with one refresh: a post missing from a CACHED corpus or // manifest is looked for again in a fresh copy (once per process for a build, // once per `refreshAfterMs` for a server), so a post published since the cache // was filled is found. A URL that failed is not asked again for as long (the // failure memo in createPostChannelResolver). import { createJsonCache } from "./cues.mjs"; import { deckOn, postNativeId, resolveDeck } from "./deck.mjs"; /** How long one archive request may take before the post links its original. */ export const POST_LINK_TIMEOUT_MS = 15000; const origin = (provenance) => { const o = typeof provenance?.siteOrigin === "string" ? provenance.siteOrigin.trim() : ""; return o ? o.replace(/\/+$/, "") : null; }; const squash = (v) => String(v ?? "").toLowerCase().replace(/^@/, ""); /** Does this corpus channel look like the account that posted? Its slug or name carries the handle. */ export function channelMatchesHandle(channel, handle) { const h = squash(handle); if (!h) return false; const first = h.split(".")[0]; const hay = [squash(channel.slug), squash(channel.name)]; return hay.some((s) => s.includes(h) || (first.length >= 3 && s.includes(first))); } /** * The resolver. `getJson` (url, { refresh }) => document is injectable for * tests; by default a cues.mjs JSON cache over `fetchImpl` with a timeout. * * @returns {{ find(origin: string, post: Record): Promise }} * `find` resolves the slug of the channel that keeps the post, or null when * none does; it rejects when the archive cannot be read. */ export function createPostChannelResolver({ getJson = null, cacheDir, fetchImpl = globalThis.fetch, log = () => {}, timeoutMs = POST_LINK_TIMEOUT_MS, refreshAfterMs = Infinity, now = Date.now, } = {}) { const timed = (url) => fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) }); const get = getJson ?? createJsonCache({ ...(cacheDir !== undefined ? { cacheDir } : {}), fetchImpl: timed, log, refreshAfterMs }).getJson; // ONE memo of failed reads, `url -> { err, at, refresh }`, consulted by both // passes (the cached read and the fresh one): a URL that failed is not asked // again until `refreshAfterMs` has passed -- never again in a build // (Infinity), after ten minutes in umtool's server -- and a read of it that // succeeds clears it. So each URL of an archive that does not answer costs // one timeout per build, not one per post. // // A cached read that failed went to the network (nothing was cached), so it // bars both passes. A fresh read that failed bars only fresh reads: the copy // already in hand still answers the cached pass, and a cached read that // succeeds does not clear it -- it proves nothing about the network. const failed = new Map(); async function read(url, refresh) { const f = failed.get(url); const live = f && now() - f.at < refreshAfterMs; if (live && (refresh || !f.refresh)) throw f.err; try { const doc = await get(url, { refresh }); if (f && (refresh || !f.refresh || !live)) failed.delete(url); return doc; } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); failed.set(url, { err, at: now(), refresh }); throw err; } } async function lookIn(base, id, handle, refresh) { const corpus = await read(`${base}/corpus.json`, refresh); const channels = (corpus?.channels ?? []).filter((c) => typeof c?.manifests?.posts === "string" && c.postCount !== 0); const ordered = [ ...channels.filter((c) => channelMatchesHandle(c, handle)), ...channels.filter((c) => !channelMatchesHandle(c, handle)), ]; for (const c of ordered) { let manifest; try { manifest = await read(c.manifests.posts, refresh); } catch { // One channel's manifest missing is that channel not answering, not the archive. continue; } if (manifest?.slugToPage && Object.hasOwn(manifest.slugToPage, id)) return c.slug; } return null; } return { async find(base, post) { const id = postNativeId(post); if (!id) return null; const at = String(base).replace(/\/+$/, ""); return (await lookIn(at, id, post.handle, false)) ?? (await lookIn(at, id, post.handle, true)); }, }; } /** * The posts with their archive channel found, and a note per post saying * where its QR goes. Only under the deck, with `posts.links: "archive"`, an * archive named (`provenance.siteOrigin`) and posts to draw; otherwise the * posts come back as they were, with no notes. A hidden post is not drawn, so * it is not looked up either. * * Never throws for the archive: a post it cannot place keeps its own link. * `deadlineMs` bounds the whole call (umtool's preview passes one): a post * still unresolved when it passes keeps its own link, and its lookup goes on * in the background, filling the cache for the next call. * * @param {{ posts?: Array>, provenance?: Record, * render?: Record, resolver: ReturnType, * deadlineMs?: number }} args * @returns {Promise<{ posts: Array>, notes: string[] }>} */ export async function resolvePostLinks({ posts = [], provenance = {}, render = {}, resolver, deadlineMs = Infinity }) { const settings = resolveDeck(render).posts; const base = origin(provenance); if (!deckOn(render) || !Array.isArray(posts) || !posts.length || !settings.show || settings.links !== "archive") { return { posts, notes: [] }; } if (!base) { return { posts, notes: ["posts: no provenance.siteOrigin names an archive -- every post's QR links the original (set provenance.siteOrigin to the site that keeps them)"], }; } const notes = []; const out = []; const until = Date.now() + deadlineMs; const timeUp = Symbol("time up"); const inTime = (promise) => { const left = until - Date.now(); if (!Number.isFinite(left)) return promise; let timer; const t = new Promise((res) => { timer = setTimeout(() => res(timeUp), left); }); promise.catch(() => {}); return Promise.race([promise, t]).finally(() => clearTimeout(timer)); }; for (const p of posts) { if (p.hide) { out.push(p); continue; } if (p.siteUrl) { notes.push(`${p.id}: QR links ${p.siteUrl} (siteUrl)`); out.push(p); continue; } if (p.siteChannel) { notes.push(`${p.id}: archive link via ${p.siteChannel} (pinned)`); out.push(p); continue; } if (!postNativeId(p)) { notes.push(`${p.id}: no post id in its url -- QR links the original`); out.push(p); continue; } if (Date.now() >= until) { notes.push(`${p.id}: no time left to ask the archive at ${base} -- QR links the original`); out.push(p); continue; } try { const slug = await inTime(resolver.find(base, p)); if (slug === timeUp) { notes.push(`${p.id}: the archive at ${base} was still being asked when time ran out -- QR links the original`); out.push(p); } else if (slug) { notes.push(`${p.id}: archive link via ${slug}`); out.push({ ...p, siteChannel: slug }); } else { notes.push(`${p.id}: not in the archive at ${base} -- QR links the original`); out.push(p); } } catch (e) { notes.push(`${p.id}: the archive at ${base} did not answer (${e instanceof Error ? e.message : String(e)}) -- QR links the original`); out.push(p); } } return { posts: out, notes }; }