diff --git a/app/api/rpc/[command]/get_profile_data.ts b/app/api/rpc/[command]/get_profile_data.ts index 6c083314..63207de3 100644 --- a/app/api/rpc/[command]/get_profile_data.ts +++ b/app/api/rpc/[command]/get_profile_data.ts @@ -5,7 +5,7 @@ import { idResolver } from "src/identity"; import { supabaseServerClient } from "supabase/serverClient"; import { Agent } from "@atproto/api"; import { getIdentityData } from "actions/getIdentityData"; -import { createOauthClient } from "src/atproto-oauth"; +import { restoreOAuthSession } from "src/atproto-oauth"; import { normalizePublicationRow, hasValidPublication, @@ -35,17 +35,10 @@ export const get_profile_data = makeRoute({ let agent; let authed_identity = await getIdentityData(); if (authed_identity?.atp_did) { - try { - const oauthClient = await createOauthClient(); - let credentialSession = await oauthClient.restore( - authed_identity.atp_did, - ); - agent = new Agent(credentialSession); - } catch (e) { - agent = new Agent({ - service: "https://public.api.bsky.app", - }); - } + const restored = await restoreOAuthSession(authed_identity.atp_did); + agent = restored.ok + ? new Agent(restored.value) + : new Agent({ service: "https://public.api.bsky.app" }); } else { agent = new Agent({ service: "https://public.api.bsky.app", diff --git a/src/atproto-oauth.ts b/src/atproto-oauth.ts index 1a7d7f77..e978709a 100644 --- a/src/atproto-oauth.ts +++ b/src/atproto-oauth.ts @@ -12,7 +12,23 @@ import { supabaseServerClient } from "supabase/serverClient"; import Client from "ioredis"; import Redlock from "redlock"; import { Result, Ok, Err } from "./result"; -export async function createOauthClient() { + +// Module-scoped singleton: NodeOAuthClient, ioredis connection, and Redlock +// have no per-request state — keys/stores live above the user — so building +// them once per Node instance avoids reconnect + keyset re-import on every call. +// Stashed on globalThis so Next.js dev hot-reload doesn't leak Redis sockets. +const globalForOauth = globalThis as unknown as { + __oauthClient?: Promise; +}; + +export function createOauthClient(): Promise { + if (!globalForOauth.__oauthClient) { + globalForOauth.__oauthClient = buildOauthClient(); + } + return globalForOauth.__oauthClient; +} + +async function buildOauthClient(): Promise { let keyset = process.env.NODE_ENV === "production" ? await Promise.all([ @@ -99,12 +115,47 @@ export type OAuthSessionError = { did: string; }; +// In-process dedupe: collapse concurrent restore() calls for the same DID into +// one underlying restore + Redlock acquisition. Successful entries linger +// briefly so a burst of requests (e.g. hover-fired ProfilePopovers) share one +// result; rejected promises evict immediately so a transient failure doesn't +// stick around poisoning subsequent calls. +const RESTORE_DEDUPE_TTL_MS = 5_000; +const inFlightRestores = new Map>(); + +function dedupedRestore(did: string): Promise { + let existing = inFlightRestores.get(did); + if (existing) return existing; + + const promise = (async () => { + const oauthClient = await createOauthClient(); + return oauthClient.restore(did); + })(); + inFlightRestores.set(did, promise); + + promise.then( + () => { + setTimeout(() => { + if (inFlightRestores.get(did) === promise) { + inFlightRestores.delete(did); + } + }, RESTORE_DEDUPE_TTL_MS); + }, + () => { + if (inFlightRestores.get(did) === promise) { + inFlightRestores.delete(did); + } + }, + ); + + return promise; +} + export async function restoreOAuthSession( did: string ): Promise> { try { - const oauthClient = await createOauthClient(); - const session = await oauthClient.restore(did); + const session = await dedupedRestore(did); return Ok(session); } catch (error) { return Err({