import { resolveFetch } from './helper' import { Fetch, FunctionInvokeOptions, FunctionRegion, FunctionsFetchError, FunctionsHttpError, FunctionsRelayError, FunctionsResponse, } from './types' /** * Client for invoking Supabase Edge Functions. */ export class FunctionsClient { protected url: string protected headers: Record protected region: FunctionRegion protected fetch: Fetch /** * Creates a new Functions client bound to an Edge Functions URL. * * @example Using supabase-js (recommended) * ```ts * import { createClient } from '@supabase/supabase-js' * * const supabase = createClient('https://xyzcompany.supabase.co', 'your-publishable-key') * const { data, error } = await supabase.functions.invoke('hello-world') * ``` * * @category Edge Functions * * @example Standalone import for bundle-sensitive environments * ```ts * import { FunctionsClient, FunctionRegion } from '@supabase/functions-js' * * const functions = new FunctionsClient('https://xyzcompany.supabase.co/functions/v1', { * headers: { apikey: 'your-publishable-key' }, * region: FunctionRegion.UsEast1, * }) * ``` */ constructor( url: string, { headers = {}, customFetch, region = FunctionRegion.Any, }: { headers?: Record customFetch?: Fetch region?: FunctionRegion } = {} ) { this.url = url this.headers = headers this.region = region this.fetch = resolveFetch(customFetch) } /** * Updates the authorization header * @param token - the new jwt token sent in the authorisation header * * @category Edge Functions * * @example Setting the authorization header * ```ts * functions.setAuth(session.access_token) * ``` */ setAuth(token: string) { this.headers.Authorization = `Bearer ${token}` } /** * Invokes a function * @param functionName - The name of the Function to invoke. * @param options - Options for invoking the Function. * @example * ```ts * const { data, error } = await functions.invoke('hello-world', { * body: { name: 'Ada' }, * }) * ``` * * @category Edge Functions * * @remarks * - The API key is sent in the `apikey` header. The `Authorization` header is reserved * for the signed-in user's JWT (or a custom auth token) — when there is no session, a * new-format API key (`sb_publishable_…` / `sb_secret_…`) is not sent as a Bearer token. * - Invoke params generally match the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) spec. * - When you pass in a body to your function, we automatically attach the Content-Type header for `Blob`, `ArrayBuffer`, `File`, `FormData` and `String`. If it doesn't match any of these types we assume the payload is `json`, serialize it and attach the `Content-Type` header as `application/json`. You can override this behavior by passing in a `Content-Type` header of your own. * - Responses are automatically parsed as `json`, `blob` and `form-data` depending on the `Content-Type` header sent by your function. Responses are parsed as `text` by default. * * @example Basic invocation * ```js * const { data, error } = await supabase.functions.invoke('hello', { * body: { foo: 'bar' } * }) * ``` * * @exampleDescription Error handling * A `FunctionsHttpError` error is returned if your function throws an error, `FunctionsRelayError` if the Supabase Relay has an error processing your function and `FunctionsFetchError` if there is a network error in calling your function. Log the full error object so fields like `name`, `context`, and any structured body aren't hidden. * * @example Error handling * ```js * import { FunctionsHttpError, FunctionsRelayError, FunctionsFetchError } from "@supabase/supabase-js"; * * const { data, error } = await supabase.functions.invoke('hello', { * headers: { * "my-custom-header": 'my-custom-header-value' * }, * body: { foo: 'bar' } * }) * * if (error instanceof FunctionsHttpError) { * const errorMessage = await error.context.json() * console.error('Function returned an error', errorMessage) * } else if (error instanceof FunctionsRelayError) { * console.error('Relay error:', error) * } else if (error instanceof FunctionsFetchError) { * console.error('Fetch error:', error) * } * ``` * * @exampleDescription Passing custom headers * You can pass custom headers to your function. Note: supabase-js automatically passes the `Authorization` header with the signed in user's JWT. * * @example Passing custom headers * ```js * const { data, error } = await supabase.functions.invoke('hello', { * headers: { * "my-custom-header": 'my-custom-header-value' * }, * body: { foo: 'bar' } * }) * ``` * * @exampleDescription Calling with DELETE HTTP verb * You can also set the HTTP verb to `DELETE` when calling your Edge Function. * * @example Calling with DELETE HTTP verb * ```js * const { data, error } = await supabase.functions.invoke('hello', { * headers: { * "my-custom-header": 'my-custom-header-value' * }, * body: { foo: 'bar' }, * method: 'DELETE' * }) * ``` * * @exampleDescription Invoking a Function in the UsEast1 region * Here are the available regions: * - `FunctionRegion.Any` * - `FunctionRegion.ApNortheast1` * - `FunctionRegion.ApNortheast2` * - `FunctionRegion.ApSouth1` * - `FunctionRegion.ApSoutheast1` * - `FunctionRegion.ApSoutheast2` * - `FunctionRegion.CaCentral1` * - `FunctionRegion.EuCentral1` * - `FunctionRegion.EuWest1` * - `FunctionRegion.EuWest2` * - `FunctionRegion.EuWest3` * - `FunctionRegion.SaEast1` * - `FunctionRegion.UsEast1` * - `FunctionRegion.UsWest1` * - `FunctionRegion.UsWest2` * * @example Invoking a Function in the UsEast1 region * ```js * import { createClient, FunctionRegion } from '@supabase/supabase-js' * * const { data, error } = await supabase.functions.invoke('hello', { * body: { foo: 'bar' }, * region: FunctionRegion.UsEast1 * }) * ``` * * @exampleDescription Calling with GET HTTP verb * You can also set the HTTP verb to `GET` when calling your Edge Function. * * @example Calling with GET HTTP verb * ```js * const { data, error } = await supabase.functions.invoke('hello', { * headers: { * "my-custom-header": 'my-custom-header-value' * }, * method: 'GET' * }) * ``` * * @example Standalone client invoke * ```ts * const { data, error } = await functions.invoke('hello-world', { * body: { name: 'Ada' }, * }) * ``` */ async invoke( functionName: string, options: FunctionInvokeOptions = {} ): Promise> { let timeoutId: ReturnType | undefined let timeoutController: AbortController | undefined let onAbort: (() => void) | undefined try { const { headers, method, body: functionArgs, signal, timeout } = options let _headers: Record = {} let { region } = options if (!region) { region = this.region } // Add region as query parameter using URL API const url = new URL(`${this.url}/${functionName}`) if (region && region !== 'any') { _headers['x-region'] = region url.searchParams.set('forceFunctionRegion', region) } let body: any // HTTP header names are case-insensitive, so detect a caller-supplied Content-Type // regardless of casing — otherwise the SDK injects a second, conflicting Content-Type. const hasContentTypeHeader = !!headers && Object.keys(headers).some((key) => key.toLowerCase() === 'content-type') if (functionArgs && !hasContentTypeHeader) { if ( (typeof Blob !== 'undefined' && functionArgs instanceof Blob) || functionArgs instanceof ArrayBuffer ) { // will work for File as File inherits Blob // also works for ArrayBuffer as it is the same underlying structure as a Blob _headers['Content-Type'] = 'application/octet-stream' body = functionArgs } else if (typeof functionArgs === 'string') { // plain string _headers['Content-Type'] = 'text/plain' body = functionArgs } else if (typeof FormData !== 'undefined' && functionArgs instanceof FormData) { // don't set content-type headers // Request will automatically add the right boundary value body = functionArgs } else { // default, assume this is JSON _headers['Content-Type'] = 'application/json' body = JSON.stringify(functionArgs) } } else { if ( functionArgs && typeof functionArgs !== 'string' && !(typeof Blob !== 'undefined' && functionArgs instanceof Blob) && !(functionArgs instanceof ArrayBuffer) && !(typeof FormData !== 'undefined' && functionArgs instanceof FormData) ) { body = JSON.stringify(functionArgs) } else { body = functionArgs } } // Handle timeout by creating an AbortController let effectiveSignal = signal if (timeout) { timeoutController = new AbortController() timeoutId = setTimeout(() => timeoutController!.abort(), timeout) // If user provided their own signal, we need to respect both if (signal) { effectiveSignal = timeoutController.signal // If the user's signal is aborted, abort our timeout controller too. // Store the listener so we can clean it up in finally. onAbort = () => timeoutController!.abort() signal.addEventListener('abort', onAbort) } else { effectiveSignal = timeoutController.signal } } const response = await this.fetch(url.toString(), { method: method || 'POST', // headers priority is (high to low): // 1. invoke-level headers // 2. client-level headers // 3. default Content-Type header headers: { ..._headers, ...this.headers, ...headers }, body, signal: effectiveSignal, }).catch((fetchError) => { throw new FunctionsFetchError(fetchError) }) const isRelayError = response.headers.get('x-relay-error') if (isRelayError && isRelayError === 'true') { throw new FunctionsRelayError(response) } if (!response.ok) { throw new FunctionsHttpError(response) } // HTTP media types are case-insensitive (RFC 9110), so normalize before // matching — otherwise an "Application/JSON" response falls through to text. let responseType = (response.headers.get('Content-Type') ?? 'text/plain') .split(';')[0] .trim() .toLowerCase() let data: any if (responseType === 'application/json') { data = await response.json() } else if ( responseType === 'application/octet-stream' || responseType === 'application/pdf' ) { data = await response.blob() } else if (responseType === 'text/event-stream') { data = response } else if (responseType === 'multipart/form-data') { data = await response.formData() } else { // default to text data = await response.text() } return { data, error: null, response } } catch (error) { return { data: null, error, response: error instanceof FunctionsHttpError || error instanceof FunctionsRelayError ? error.context : undefined, } } finally { // Clear the timeout if it was set if (timeoutId) { clearTimeout(timeoutId) } // Remove the cross-signal listener to prevent memory leaks when the caller // reuses the same AbortSignal across multiple invocations. if (onAbort) { options.signal?.removeEventListener('abort', onAbort) } } } }