import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { Agent } from 'undici'; let _admin: SupabaseClient | null = null; /** * Dedicated undici dispatcher for all admin-client traffic (GoTrue + PostgREST). * * ROOT CAUSE this addresses: GoTrue/undici intermittently returns an empty or * truncated HTTP body when a pooled keep-alive connection is REUSED right after * a write (bulk ban/role/delete) and then immediately read back (the auto list * refresh). supabase-js then throws `Unexpected end of JSON input` and the * route 500s. Node 24's bundled undici reuses connections more aggressively, * amplifying the race. * * `pipelining: 0` disables request pipelining on a connection, and the bounded * keep-alive timeouts keep idle sockets from lingering long enough to be reused * in a half-closed state. When a request DOES still hit a poisoned socket, * undici destroys that socket on error, so the `withAdminRetry` wrapper at the * call sites lands on a FRESH connection rather than the same dead one. The two * layers together eliminate the empty-body 500s. * * NOTE: `setGlobalDispatcher` from the npm `undici` package does NOT affect * Node's built-in global `fetch` (it bundles its own undici), so we attach this * dispatcher explicitly via the `dispatcher` init option, which built-in fetch * does honour. */ const adminDispatcher = new Agent({ pipelining: 0, keepAliveTimeout: 10_000, keepAliveMaxTimeout: 10_000, }); export function getSupabaseAdmin(): SupabaseClient { if (_admin) return _admin; const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!url || !key) { throw new Error('Supabase admin env not configured'); } _admin = createClient(url, key, { auth: { autoRefreshToken: false, persistSession: false }, global: { // Force every request the admin client makes (GoTrue listUsers and // PostgREST reads alike) to bypass Next's fetch Data Cache, so the admin // surface always reflects current state regardless of page config, and // route it through the dedicated keep-alive-tamed dispatcher above. fetch: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, { ...init, cache: 'no-store', dispatcher: adminDispatcher, } as RequestInit), }, }); return _admin; } export function getSupabaseAnon(): SupabaseClient { const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; if (!url || !key) { throw new Error('Supabase anon env not configured'); } return createClient(url, key, { auth: { autoRefreshToken: false, persistSession: false }, }); }