Stirling-Tools/Stirling-PDF · error · StripeFunctionError

quote PDF response was not a file

Error message

quote PDF response was not a file

What it means

Thrown by fetchBundleQuotePdf when the GET invocation resolves without error but the returned data is not a Blob. The route is expected to stream application/pdf; any other body shape (JSON error object, text, undefined) fails the instanceof Blob check.

Source

Thrown at frontend/editor/src/portal/billing/stripe.ts:481

 */
export async function fetchBundleQuotePdf(quoteId: number): Promise<Blob> {
  ensureSaasSupabase();
  const supabase = getSupabaseClient();
  if (!supabase) {
    throw new StripeFunctionError(
      "SaaS Supabase not configured — set VITE_SUPABASE_URL.",
      "unconfigured",
    );
  }
  const { data, error } = await supabase.functions.invoke<Blob>(
    `create-payg-bundle-quote?quote_id=${quoteId}`,
    { method: "GET" },
  );
  if (error) {
    throw new StripeFunctionError(error.message ?? "quote PDF fetch failed");
  }
  if (!(data instanceof Blob)) {
    throw new StripeFunctionError("quote PDF response was not a file");
  }
  return data;
}

/**
 * {@code VITE_STRIPE_PUBLISHABLE_KEY} — the Stripe pk used by embedded Checkout. Coalesces to "" when
 * unset so the declared `string` return type is honest (Vite substitutes `undefined` for a missing
 * env var); callers guard with a falsy check.
 */
export function getStripePublishableKey(): string {
  return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
}

// Process-wide memoized Stripe.js loader, shared by the embedded-checkout modals so the SDK promise
// is created once rather than per-modal. loadStripe is dynamically imported so its chunk only loads
// when a checkout modal reaches its payment step.
let stripePromise: Promise<Stripe | null> | null = null;
export function loadStripeOnce(pk: string): Promise<Stripe | null> {

View on GitHub (pinned to 9ef20dcab8)

Solutions

  1. Confirm the GET route always streams the PDF with Content-Type: application/pdf on success.
  2. On the function's error paths, return a proper HTTP error status (not 200 + JSON) so the invoke error branch (157) fires instead.
  3. Check the supabase-js version — Blob parsing depends on the response content-type.
  4. Log the actual data type/constructor in dev to identify what was returned.

Example fix

// edge function (GET route) — before
if (!pdf) return json({ error: 'not_found' }, { status: 200 });

// after — error paths use a non-2xx status so invoke rejects
if (!pdf) return json({ error: 'not_found' }, { status: 404 });
return new Response(pdf, { headers: { 'Content-Type': 'application/pdf' } });
Defensive patterns

Strategy: type-guard

Type guard

function isPdfBlob(data: unknown): data is Blob {
  return typeof Blob !== "undefined" && data instanceof Blob && data.size > 0;
}

Try / catch

try {
  const data = await fetchBundleQuotePdf(quoteId); // throws if not a Blob
  downloadBlob(data, `quote-${quoteId}.pdf`);
} catch (e) {
  if (e instanceof StripeFunctionError && /not a file/i.test(e.message)) {
    notifications.show({ message: "Quote PDF came back in the wrong format.", color: "red" });
  } else throw e;
}

Prevention

When it happens

Trigger: create-payg-bundle-quote GET returns successfully but the body is not a Blob — e.g. the function returned a JSON error object instead of streaming the PDF, or supabase-js parsed the response into a plain object because the content-type wasn't application/pdf.

Common situations: The function returned an error as JSON (e.g. { error: 'not found' }) with status 200; the route forgot to set Content-Type: application/pdf; a proxy re-wrapped the body; supabase-js version changed Blob handling for non-binary content-types.

Related errors


AI-assisted analysis of Stirling-Tools/Stirling-PDF@9ef20dcab8 (2026-08-13). Data as JSON: /api/errors/211f9090c96e619f. Report an issue: GitHub.