npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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-js

Usage

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/hello

For 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 = false
Netcash → POST https://api.example.com/netcash-notify
        → proxy hub (Host: api.example.com → site lookup)
        → https://<ref>.functions.supabase.co/netcash-notify

The 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 with SupabaseProxyBootstrapTimeoutError so you can distinguish it from a generic network failure.
  • Retry with backoff. Network errors, timeouts, and 5xx responses are retried up to retries times (default 2) with exponential backoff starting at retryBackoffMs (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 localStorage under supabase-proxy-bootstrap:<hash-of-url> with a timestamp. On the next page load, a cached entry within its maxAge window 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 with bootstrapStaleWhileRevalidate: false.
  • Cache-Control awareness. When the bootstrap response includes Cache-Control: max-age=N, that value overrides bootstrapMaxAgeMs for the resulting cache entry. The hub currently sends max-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 exceeded bootstrapTimeoutMs. Exposes .timeoutMs and .url.
  • SupabaseProxyBootstrapHttpError extends SupabaseProxyBootstrapError — the bootstrap endpoint returned a non-2xx status. Exposes .status and .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 Host header against sites.custom_domain, sites.data_custom_domain, or sites.functions_custom_domain.
  • All values are public-by-design — safe to ship to the browser.
  • functionsDomain is null when the site has no Functions proxy configured (or when supabase_url isn'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 build

Publish a new version by tagging:

npm version patch  # or minor / major
git push --follow-tags

The publish.yml workflow publishes the tag to the public npm registry.