File size: 12,334 Bytes
c212805 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 | 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<string, string>
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<string, string>
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<T = any>(
functionName: string,
options: FunctionInvokeOptions = {}
): Promise<FunctionsResponse<T>> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
let timeoutController: AbortController | undefined
let onAbort: (() => void) | undefined
try {
const { headers, method, body: functionArgs, signal, timeout } = options
let _headers: Record<string, string> = {}
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)
}
}
}
}
|