Back to mini-nostr-saf
SOURCE / PINNED RELEASE

Made of little things.

mini-nostr-saf

Release
72c0506a4a1b…
Author-recorded commit
cec3876ccbdc…
License
LICENSE
Author’s source reference
nostr://npub1whs094x2p7qshzqn77uwm0kx0826f8xdad5ppqet7w9jr6ay5scs99c7wj/wss%3A%2F%2Fgit.napplet.soy%2F/n-599618f6842

Archive hash verified: e3147c802508002b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/relay-picks.ts
/**
 * 每日推荐中继 API(lulin.org):按日期种子从收集中继池随机抽 3 个,附 NIP-11 简介。
 *
 * Napplet 沙箱禁止 raw fetch;只读外部字节走 host 的 resource 域。
 * 抓取器可注入,便于测试;默认走 @napplet/sdk 的 resource.bytes。
 */

export const RELAY_PICKS_URL = "https://lulin.org/client/api/relay-picks.json";

/** 单次抓取超时:host 的 resource.bytes 可能悬挂(无拒绝、无解决),不能无限等。 */
export const RELAY_PICKS_TIMEOUT_MS = 12_000;

export type RelayPick = { url: string; name: string; description: string };
export type RelayPicks = { date: string; pool_size: number; picks: RelayPick[] };

export type BytesFetcher = (
  url: string,
  init?: { signal?: AbortSignal },
) => Promise<Blob>;

/** 推荐 URL:?date= 避开 SW cache-first 缓存到的旧文件(每天换 URL 即换缓存键)。 */
export function relayPicksUrl(date: Date = new Date()): string {
  return `${RELAY_PICKS_URL}?date=${date.toISOString().slice(0, 10)}`;
}

export function isRelayPicks(value: unknown): value is RelayPicks {
  if (typeof value !== "object" || value === null) return false;
  const doc = value as Record<string, unknown>;
  return (
    typeof doc.date === "string" &&
    Array.isArray(doc.picks) &&
    doc.picks.every(
      (pick) =>
        typeof pick === "object" &&
        pick !== null &&
        typeof (pick as Record<string, unknown>).url === "string",
    )
  );
}

async function defaultBytesFetcher(
  url: string,
  init?: { signal?: AbortSignal },
): Promise<Blob> {
  const { resource } = await import("@napplet/sdk");
  return resource.bytes(url, init);
}

async function fetchBytesWithTimeout(
  fetcher: BytesFetcher,
  url: string,
  timeoutMs: number,
): Promise<Blob> {
  // 注意:不能只靠 AbortSignal——不遵守 signal 的 host 照样悬挂,
  // 必须用 Promise.race 硬超时,悬挂的 promise 丢弃即可。
  const ctrl = new AbortController();
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => {
      ctrl.abort();
      reject(new Error(`resource.bytes timeout after ${timeoutMs}ms: ${url}`));
    }, timeoutMs);
  });
  try {
    return await Promise.race([fetcher(url, { signal: ctrl.signal }), timeout]);
  } finally {
    if (timer !== undefined) clearTimeout(timer);
  }
}

/**
 * 拉取今日推荐;若当日文件尚未生成则回退到无参 URL(昨日的)。
 * 保证:永不抛错(失败返回 null),永不悬挂(每次抓取有超时)。
 */
export async function fetchRelayPicks(
  fetcher: BytesFetcher = defaultBytesFetcher,
  timeoutMs: number = RELAY_PICKS_TIMEOUT_MS,
): Promise<RelayPicks | null> {
  for (const url of [relayPicksUrl(), RELAY_PICKS_URL]) {
    try {
      const blob = await fetchBytesWithTimeout(fetcher, url, timeoutMs);
      const doc: unknown = JSON.parse(await blob.text());
      if (isRelayPicks(doc)) return doc;
    } catch {
      // 超时、被 host 策略拦截、网络错误、JSON 非法都走这里:换下一个 URL 试。
    }
  }
  return null;
}