fix(admin): eliminate GoTrue empty-body 500s under bulk load (retry-all + undici keep-alive + sequential bulk), CSV formula-injection guard

This commit is contained in:
Gerhard Scheikl
2026-05-31 17:30:04 +02:00
parent cbd29445bb
commit 8e8df7ae64
12 changed files with 134 additions and 28 deletions
+36 -2
View File
@@ -1,7 +1,36 @@
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;
@@ -14,9 +43,14 @@ export function getSupabaseAdmin(): SupabaseClient {
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.
// 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' }),
fetch(input, {
...init,
cache: 'no-store',
dispatcher: adminDispatcher,
} as RequestInit),
},
});
return _admin;