@justin-netage/supabase-proxy-client
v1.6.0
Published
Transparent Supabase proxy client for the Netage Proxy Hub. Lets a Lovable/Vite frontend route Supabase traffic through a per-site proxy domain, with config fetched at runtime.
Maintainers
Readme
@justin-netage/supabase-proxy-client
Transparent Supabase proxy client for the Netage Proxy Hub. Lets a Vite/React frontend route Supabase traffic through a per-site reverse proxy, with zero per-project configuration. Each site's config (Supabase project ref, anon key, proxy custom domain) is fetched at runtime from the proxy hub based on the current hostname.
Why
Some build pipelines strip or override custom Vite env vars (VITE_SUPABASE_URL_OG, VITE_SUPABASE_PROJECT_ID, sometimes user-set VITE_SUPABASE_URL), so an env-driven proxy rewrite silently no-ops in deployed bundles. Client config therefore has to come from a runtime API call — which is what this package does.
Architecture
┌─────────────┐ 1. GET /api/bootstrap ┌─────────────────┐
│ customer │ ────────────────────▶ │ netage- │
│ site (SPA) │ │ proxy-hub │
│ │ ◀──────────────────── │ │
│ │ 2. { projectRef, anonKey, │ looks up site │
│ │ proxyDomain, │ by Host header │
│ │ functionsDomain? } │ │
│ │ └─────────────────┘
│ │
│ │ 3. createClient(proxyDomain, anonKey)
│ │ 4. all subsequent Supabase REST/Auth/Storage → proxy-hub
│ │ 5. supabase.functions.invoke() → functionsDomain (if set)
└─────────────┘Install
npm install @justin-netage/supabase-proxy-client @supabase/supabase-jsUsage
Drop this in src/lib/supabase.ts — identical across every project:
import { initProxiedSupabase } from '@justin-netage/supabase-proxy-client';
export const { supabase, proxyUrl } = await initProxiedSupabase({
// Optional: used as a fallback when bootstrap fetch fails (localhost dev).
dev: {
projectRef: 'abc123',
proxyDomain: 'http://localhost:54321',
anonKey: 'eyJ...',
},
});Use proxyUrl() to rewrite stored Supabase URLs (e.g. legacy getPublicUrl() results persisted to a row) at render time:
<img src={proxyUrl(row.image_url)} alt={row.title} />Calling Supabase Functions
When the hub is configured with a functionsCustomDomain for the site, the client automatically points supabase.functions.invoke() at that domain — no extra setup required:
const { data, error } = await supabase.functions.invoke('hello', {
body: { name: 'world' },
});
// → POST https://api.example.com/helloFor payment-gateway webhooks (Netcash, Stripe, etc.), the gateway POSTs directly to the same hostname and the proxy passes the Authorization header through unchanged. The upstream Function must be deployed with verify_jwt = false so it accepts unauthenticated callbacks:
# supabase/functions/netcash-notify/config.toml
verify_jwt = falseNetcash → POST https://api.example.com/netcash-notify
→ proxy hub (Host: api.example.com → site lookup)
→ https://<ref>.functions.supabase.co/netcash-notifyThe hub derives the Functions upstream from the site's Supabase URL (it's the same <ref>), so you only configure the customer-facing custom domain — there's no separate "Functions URL" field to fill in.
Synchronous variant (Pattern A)
If you'd rather hardcode config and skip the bootstrap fetch:
import { createProxiedSupabase } from '@justin-netage/supabase-proxy-client';
export const { supabase, proxyUrl } = createProxiedSupabase({
projectRef: 'abc123',
proxyDomain: 'https://data.example.com',
functionsDomain: 'https://api.example.com', // optional — omit if you don't proxy Functions
anonKey: 'eyJ...',
});Sending mail from a browser form
initProxiedSupabase returns a mail client wired to the hub's public
POST /api/mail/send endpoint for the current site. Use it for contact /
enquiry forms. The hub identifies the site by Host header and fills in the
from-address, recipient defaults, and rate limits from the site's dashboard
config; the browser only supplies the message:
const { mail } = await initProxiedSupabase();
const res = await mail.sendMail({
subject: 'New enquiry',
html: '<p>…</p>',
formType: 'contact',
});
if (!res.ok) showError(res.error);Sending mail from a backend (server-only)
Browser forms can't cover transactional mail that fires outside a form —
order confirmations from a payment webhook, welcome emails from an edge
function, alerts from a cron job. Those run server-side with no browser
Origin and no captcha, so they use a separate authenticated endpoint:
POST /api/mail/send-key, with the site's per-site api_key as a bearer
token.
import { createServerMailClient } from '@justin-netage/supabase-proxy-client';
// e.g. inside a Supabase Edge Function (Deno)
const mail = createServerMailClient({
apiKey: Deno.env.get('PROXY_HUB_SITE_KEY')!, // server secret — NEVER in the browser
baseUrl: 'https://data.example.com', // hub origin; /api/mail/send-key is appended
});
const res = await mail.sendMail({
to: order.customerEmail,
subject: `Order ${order.ref} confirmed`,
html,
formType: 'order-confirmation',
});The hub resolves the site from the key and still enforces the site's
mail-enabled gate, sender-domain authorisation, recipient defaults, and
mail_log audit trail. The api_key is a secret — keep it in your
server's secret store and never import createServerMailClient into code
that ships to the browser. See the hub's docs/MAIL_SEND_KEY.md for the
full endpoint contract.
Attachments (server-only)
sendMail() on the server client accepts an attachments array — e.g.
a generated PDF invoice, or an inline logo referenced from the html as
<img src="cid:logo-1">:
await mail.sendMail({
to: order.customerEmail,
subject: `Invoice ${order.ref}`,
html,
attachments: [
{ content: invoiceBase64, filename: `invoice_${order.ref}.pdf`, type: 'application/pdf' },
{ content: logoBase64, filename: 'logo.png', type: 'image/png', disposition: 'inline', contentId: 'logo-1' },
],
});Each attachment is { content, filename, type?, disposition?, contentId? }
with content as base64 of the file bytes. Hub limits: max 5
attachments per message, total base64 content ≤ 10 MB (~7.5 MB
decoded), filename free of path separators and CR/LF, and
disposition: 'inline' requires a contentId. Attachments are accepted
only on the authenticated /send-key route — the public browser
/send route rejects a body that carries them, so the browser client's
SendMailInput deliberately has no attachments field.
API
initProxiedSupabase(options?)
Fetches /api/bootstrap from the current origin (or from options.bootstrapUrl), constructs a Supabase client pointed at the proxy domain, and returns { supabase, proxyUrl, mail, config }.
| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| bootstrapUrl | string | ${location.origin}/api/bootstrap | Where to fetch the runtime config |
| dev | BootstrapConfig | — | Inline fallback used if the fetch throws (after retries) |
| clientOptions | SupabaseClientOptions | — | Forwarded to createClient |
| fetch | typeof fetch | global fetch | Inject for tests |
| bootstrapTimeoutMs | number | 8000 | Per-attempt timeout. On expiry the call rejects with SupabaseProxyBootstrapTimeoutError |
| retries | number | 2 | Additional attempts after the first failed attempt. Only triggered on network errors, timeouts, or 5xx |
| retryBackoffMs | number | 500 | Initial delay between retries; doubled per attempt (500 → 1000 → 2000…) |
| bootstrapMaxAgeMs | number | 3600000 | How long a persisted (localStorage) entry stays usable. Server Cache-Control: max-age=N overrides on a per-entry basis |
| bootstrapStaleWhileRevalidate | boolean | true | Set to false to always wait for a fresh fetch instead of serving a cached entry + revalidating in the background |
Resilience
initProxiedSupabase defends consumers against a slow or failed bootstrap:
- Timeout. Each fetch attempt is bounded by
bootstrapTimeoutMs(default 8s). On expiry the in-flight request is aborted and the call rejects withSupabaseProxyBootstrapTimeoutErrorso you can distinguish it from a generic network failure. - Retry with backoff. Network errors, timeouts, and 5xx responses are retried up to
retriestimes (default 2) with exponential backoff starting atretryBackoffMs(default 500ms). 4xx responses are surfaced immediately — they aren't going to fix themselves. - Persisted stale-while-revalidate cache. In a browser, the resolved bootstrap is written to
localStorageundersupabase-proxy-bootstrap:<hash-of-url>with a timestamp. On the next page load, a cached entry within itsmaxAgewindow is returned immediately while a background revalidation refreshes the cache. This means a flaky bootstrap doesn't gate first paint after the first successful load. Opt out withbootstrapStaleWhileRevalidate: false. - Cache-Control awareness. When the bootstrap response includes
Cache-Control: max-age=N, that value overridesbootstrapMaxAgeMsfor the resulting cache entry. The hub currently sendsmax-age=30, stale-while-revalidate=60.
resetProxiedSupabase()
Clears the in-memory cache, any in-flight bootstrap promise, every persisted localStorage entry written by this package, and resets the bootstrap state machine to 'idle'. The next call to initProxiedSupabase re-bootstraps from scratch. Use this to recover from a bad bootstrap without forcing a full page reload:
import {
initProxiedSupabase,
resetProxiedSupabase,
} from '@justin-netage/supabase-proxy-client';
async function recover() {
resetProxiedSupabase();
return initProxiedSupabase();
}subscribeBootstrapState(listener)
Subscribe to bootstrap state transitions so you can render real error UI instead of a spinner that never resolves. The listener is invoked synchronously with the current state on subscription and then on each transition. Returns an unsubscribe function.
import { subscribeBootstrapState } from '@justin-netage/supabase-proxy-client';
const unsubscribe = subscribeBootstrapState((state) => {
// state: { status: 'idle' | 'loading' | 'ready' | 'error', error?: Error }
if (state.status === 'error') {
showFatalError(state.error);
}
});getBootstrapState() returns the current state synchronously without subscribing.
Error classes
SupabaseProxyBootstrapError— base class for all bootstrap failures.SupabaseProxyBootstrapTimeoutError extends SupabaseProxyBootstrapError— a fetch attempt exceededbootstrapTimeoutMs. Exposes.timeoutMsand.url.SupabaseProxyBootstrapHttpError extends SupabaseProxyBootstrapError— the bootstrap endpoint returned a non-2xx status. Exposes.statusand.statusText.
createProxiedSupabase(config, clientOptions?)
Synchronous. Skips the network round-trip.
proxyUrl(url)
Returned from both init functions. Rewrites https://<projectRef>.supabase.co/<rest> to <proxyDomain>/<rest>. Falsy input → ''. Non-matching input passes through.
Server contract
The proxy hub's GET /api/bootstrap endpoint returns:
{
"projectRef": "abc123",
"proxyDomain": "https://data.example.com",
"functionsDomain": "https://api.example.com",
"anonKey": "eyJ..."
}- Looked up by the request's
Hostheader againstsites.custom_domain,sites.data_custom_domain, orsites.functions_custom_domain. - All values are public-by-design — safe to ship to the browser.
functionsDomainisnullwhen the site has no Functions proxy configured (or whensupabase_urlisn't a canonical Supabase host the upstream URL can be derived from); older hub deployments omit the field entirely.- Cached for ~30s via
Cache-Control: public, max-age=30, stale-while-revalidate=60. - Rate-limited per IP (60 requests / 15 minutes).
Development
npm install
npm test
npm run buildPublish a new version by tagging:
npm version patch # or minor / major
git push --follow-tagsThe publish.yml workflow publishes the tag to the public npm registry.
