fix: selfhost edge URLs (#47861)

This commit is contained in:
Vaibhav authored and GitHub committed 2026-07-16 14:06:40 +01:00
1 parent dac5756295
commit 05d8dd356c
14 files changed
+196 -69

No files matched your search

@@ -21,22 +21,11 @@ import {
} from '@/data/table-editor/table-editor-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
import { isEdgeFunctionUrl } from '@/lib/api/edgeFunctions'
import { uuidv4 } from '@/lib/helpers'
export type HTTPArgument = { id: string; name: string; value: string }
export const isEdgeFunction = ({
ref,
restUrlTld,
url,
}: {
ref?: string
restUrlTld?: string
url: string
}) =>
url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) ||
url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`)
const FORM_ID = 'edit-hook-panel-form'
const parseHeaders = (selectedHook?: PGTrigger): HTTPArgument[] => {
@@ -143,7 +132,6 @@ export const EditHookPanel = () => {
const isSubmitting = isCreating || isUpdating || isLoadingTable
const restUrl = project?.restUrl
const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
const form = useForm<WebhookFormValues>({
resolver: zodResolver(FormSchema),
@@ -152,11 +140,7 @@ export const EditHookPanel = () => {
table_id: selectedHook?.table_id?.toString() ?? '',
http_url: selectedHook?.function_args?.[0] ?? '',
http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST',
function_type: isEdgeFunction({
ref,
restUrlTld,
url: selectedHook?.function_args?.[0] ?? '',
})
function_type: isEdgeFunctionUrl(selectedHook?.function_args?.[0] ?? '', ref ?? '', restUrl)
? 'supabase_function'
: 'http_request',
timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000),
@@ -188,11 +172,7 @@ export const EditHookPanel = () => {
table_id: selectedHook?.table_id?.toString() ?? '',
http_url: selectedHook?.function_args?.[0] ?? '',
http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST',
function_type: isEdgeFunction({
ref,
restUrlTld,
url: selectedHook?.function_args?.[0] ?? '',
})
function_type: isEdgeFunctionUrl(selectedHook?.function_args?.[0] ?? '', ref ?? '', restUrl)
? 'supabase_function'
: 'http_request',
timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000),
@@ -201,7 +181,7 @@ export const EditHookPanel = () => {
httpParameters: parseParameters(selectedHook),
})
}
}, [visible, selectedHook, ref, restUrlTld, form])
}, [visible, selectedHook, ref, restUrl, form])
const queryClient = useQueryClient()
const onSubmit: SubmitHandler<WebhookFormValues> = async (values) => {
@@ -22,7 +22,6 @@ import {
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { isEdgeFunction } from './EditHookPanel'
import { WebhookFormValues } from './EditHookPanel.constants'
import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants'
import { HTTPHeaders } from './HTTPHeaders'
@@ -38,6 +37,7 @@ import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-quer
import { useTableNamesQuery } from '@/data/tables/table-names-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { buildDatabaseEdgeFunctionUrl, isEdgeFunctionUrl } from '@/lib/api/edgeFunctions'
import { uuidv4 } from '@/lib/helpers'
export interface FormContentsProps {
@@ -50,7 +50,6 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => {
const { data: project } = useSelectedProjectQuery()
const restUrl = project?.restUrl
const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: keys = [] } = useAPIKeysQuery(
@@ -75,7 +74,7 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => {
useEffect(() => {
if (!isSuccessEdgeFunctions) return
const isEdgeFunctionSelected = isEdgeFunction({ ref, restUrlTld, url: httpUrl })
const isEdgeFunctionSelected = isEdgeFunctionUrl(httpUrl, ref ?? '', restUrl)
if (httpUrl && isEdgeFunctionSelected) {
const fnSlug = httpUrl.split('/').at(-1)
@@ -98,8 +97,16 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => {
form.setValue('httpHeaders', updatedHttpHeaders)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [httpUrl, isSuccessEdgeFunctions])
}, [
form,
functions,
httpHeaders,
httpUrl,
isSuccessEdgeFunctions,
legacyServiceRole,
ref,
restUrl,
])
return (
<div>
@@ -235,9 +242,13 @@ export const FormContents = ({ form, selectedHook }: FormContentsProps) => {
} else if (functionType === 'supabase_function') {
// Default to first edge function in the list
const fnSlug = functions[0]?.slug
const defaultFunctionUrl = `https://${ref}.supabase.${restUrlTld}/functions/v1/${fnSlug}`
const defaultFunctionUrl = buildDatabaseEdgeFunctionUrl(
fnSlug ?? '',
ref ?? '',
restUrl
)
const currentUrl = form.getValues('http_url')
if (!isEdgeFunction({ ref, restUrlTld, url: currentUrl })) {
if (!isEdgeFunctionUrl(currentUrl, ref ?? '', restUrl)) {
form.setValue('http_url', defaultFunctionUrl, { shouldDirty: false })
}
}
@@ -23,6 +23,7 @@ import {
} from '@/components/ui/Forms/FormSection'
import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { buildDatabaseEdgeFunctionUrl } from '@/lib/api/edgeFunctions'
interface HTTPRequestConfigProps {
form: UseFormReturn<WebhookFormValues>
@@ -116,8 +117,7 @@ export const HTTPRequestConfig = ({ form }: HTTPRequestConfigProps) => {
<SelectContent>
{edgeFunctions.map((fn) => {
const restUrl = selectedProject?.restUrl
const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
const functionUrl = `https://${ref}.supabase.${restUrlTld}/functions/v1/${fn.slug}`
const functionUrl = buildDatabaseEdgeFunctionUrl(fn.slug, ref ?? '', restUrl)
return (
<SelectItem key={fn.id} value={functionUrl}>
@@ -19,6 +19,7 @@ import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useDatabaseHooksQuery } from '@/data/database-triggers/database-triggers-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { isEdgeFunctionUrl } from '@/lib/api/edgeFunctions'
import { BASE_PATH } from '@/lib/constants'
export interface HookListProps {
@@ -38,7 +39,6 @@ export const HookList = ({ schema, filterString }: HookListProps) => {
const [, setSelectedHookIdToDelete] = useQueryState('delete', parseAsString.withDefault(''))
const restUrl = project?.restUrl
const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
const filteredHooks = (hooks ?? []).filter(
(x) =>
@@ -54,10 +54,8 @@ export const HookList = ({ schema, filterString }: HookListProps) => {
return (
<>
{filteredHooks.map((x) => {
const isEdgeFunction = (url: string) =>
url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) ||
url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`)
const [url, method] = x.function_args
const isEdgeFunction = isEdgeFunctionUrl(url, ref ?? '', restUrl)
return (
<Table.tr key={x.id}>
@@ -66,7 +64,7 @@ export const HookList = ({ schema, filterString }: HookListProps) => {
<div>
<Image
src={
isEdgeFunction(url)
isEdgeFunction
? `${BASE_PATH}/img/function-providers/supabase-severless-function.png`
: `${BASE_PATH}/img/function-providers/http-request.png`
}
@@ -74,7 +72,7 @@ export const HookList = ({ schema, filterString }: HookListProps) => {
layout="fixed"
width="20"
height="20"
title={isEdgeFunction(url) ? 'Supabase Edge Function' : 'HTTP Request'}
title={isEdgeFunction ? 'Supabase Edge Function' : 'HTTP Request'}
/>
</div>
<p title={x.name} className="truncate">
@@ -37,10 +37,6 @@ export const TerminalInstructions = forwardRef<
const apiKey = publishableKey?.api_key ?? anonKey?.api_key ?? '[YOUR ANON KEY]'
// get the .co or .net TLD from the restUrl
const restUrl = `https://${endpoint}`
const restUrlTld = !!endpoint ? new URL(restUrl).hostname.split('.').pop() : 'co'
const commands: Commands[] = [
{
command: 'supabase functions new hello-world',
@@ -68,7 +64,7 @@ export const TerminalInstructions = forwardRef<
comment: 'Deploy your function',
},
{
command: `curl -L -X POST 'https://${projectRef}.supabase.${restUrlTld}/functions/v1/hello-world' -H 'Authorization: Bearer ${apiKey}'${anonKey?.type === 'publishable' ? ` -H 'apikey: ${apiKey}'` : ''} --data '{"name":"Functions"}'`,
command: `curl -L -X POST '${functionsEndpoint}/hello-world' -H 'Authorization: Bearer ${apiKey}'${anonKey?.type === 'publishable' ? ` -H 'apikey: ${apiKey}'` : ''} --data '{"name":"Functions"}'`,
description: 'Invokes the hello-world function',
jsx: () => {
return (
@@ -122,7 +122,11 @@ export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCro
'extensions'
)
const cronJobValues = parseCronJobCommand(selectedCronJob?.command || '', project?.ref!)
const cronJobValues = parseCronJobCommand(
selectedCronJob?.command || '',
project?.ref!,
project?.restUrl
)
const defaultValues = {
name: selectedCronJob?.jobname || '',
@@ -54,7 +54,7 @@ export const CronJobPage = () => {
const { data: edgeFunctions = [] } = useEdgeFunctionsQuery({ projectRef: project?.ref })
// Parse the cron job command to check if it's an edge function
const cronJobValues = parseCronJobCommand(job?.command || '', project?.ref!)
const cronJobValues = parseCronJobCommand(job?.command || '', project?.ref!, project?.restUrl)
const edgeFunction =
cronJobValues.type === 'edge_function' ? cronJobValues.edgeFunctionName : undefined
const edgeFunctionSlug = edgeFunction?.split('/functions/v1/').pop()
@@ -151,6 +151,24 @@ describe('parseCronJobCommand', () => {
})
})
it('should return an edge function config for a self-hosted function URL', () => {
const command = `select net.http_post( url:='http://kong:8000/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 );`
expect(parseCronJobCommand(command, 'default', undefined, false)).toMatchObject({
edgeFunctionName: 'http://kong:8000/functions/v1/_',
type: 'edge_function',
})
})
it('should return an edge function config for a self-hosted public function URL', () => {
const command = `select net.http_post( url:='https://default.supabase.localhost/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 );`
expect(
parseCronJobCommand(command, 'default', 'https://default.supabase.localhost/rest/v1/', false)
).toMatchObject({
edgeFunctionName: 'https://default.supabase.localhost/functions/v1/_',
type: 'edge_function',
})
})
it("should return an HTTP request config when there's a query parameter or hash in the URL (also handles edge function)", () => {
const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_?first=1#second=2', headers:=jsonb_build_object('Authorization', 'Bearer something'), timeout_milliseconds:=5000 )`
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
@@ -7,6 +7,8 @@ import { CronJobType } from './CreateCronJobSheet/CreateCronJobSheet.constants'
import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.constants'
import { CronJobTableCell } from './CronJobTableCell'
import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
import { isEdgeFunctionUrl } from '@/lib/api/edgeFunctions'
import { IS_PLATFORM } from '@/lib/constants'
const unescapeSqlLiteral = (value = '', isEscapeString = false) => {
const unescaped = value.replaceAll("''", "'")
@@ -129,7 +131,12 @@ const DEFAULT_CRONJOB_COMMAND = {
httpBody: '',
} as const
export const parseCronJobCommand = (originalCommand: string, projectRef: string): CronJobType => {
export const parseCronJobCommand = (
originalCommand: string,
projectRef: string,
restUrl?: string,
isPlatform = IS_PLATFORM
): CronJobType => {
const command = originalCommand.replaceAll('$$', ' ').replaceAll(/\n/g, ' ').trim()
if (command.toLocaleLowerCase().match(/^select\s+net\./)) {
@@ -181,8 +188,7 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string)
} catch {}
if (
url.includes(`${projectRef}.supabase.`) &&
url.includes('/functions/v1/') &&
isEdgeFunctionUrl(url, projectRef, restUrl, isPlatform) &&
searchParams.length === 0 &&
urlHash.length === 0
) {
@@ -32,7 +32,7 @@ export const DeleteCronJob = () => {
} = useDatabaseCronJobDeleteMutation({
onSuccess: () => {
if (cronJob && project) {
const { type } = parseCronJobCommand(cronJob.command, project.ref)
const { type } = parseCronJobCommand(cronJob.command, project.ref, project.restUrl)
track('cron_job_removed', { type })
}
toast.success(`Successfully removed cron job`)
@@ -37,17 +37,12 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants'
import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { buildDatabaseEdgeFunctionUrl } from '@/lib/api/edgeFunctions'
interface HTTPRequestFieldsProps {
form: UseFormReturn<CreateCronJobForm>
}
const buildFunctionUrl = (slug: string, projectRef: string, restUrl?: string) => {
const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
const functionUrl = `https://${projectRef}.supabase.${restUrlTld}/functions/v1/${slug}`
return functionUrl
}
export const EdgeFunctionSection = ({ form }: HTTPRequestFieldsProps) => {
const { ref } = useParams()
const { data: selectedProject } = useSelectedProjectQuery()
@@ -62,7 +57,11 @@ export const EdgeFunctionSection = ({ form }: HTTPRequestFieldsProps) => {
() =>
functions?.map((fn) => ({
...fn,
url: buildFunctionUrl(fn.slug, selectedProject?.ref || '', selectedProject?.restUrl),
url: buildDatabaseEdgeFunctionUrl(
fn.slug,
selectedProject?.ref || '',
selectedProject?.restUrl
),
})) ?? [],
[functions, selectedProject]
)
@@ -40,18 +40,10 @@ export const EdgeFunctionRenderer = ({
const functionUrl = useMemo(() => {
const endpoint = settings?.app_config?.endpoint
const protocol = settings?.app_config?.protocol ?? 'https'
if (!endpoint || !ref || !functionName) return undefined
try {
const url = new URL(`https://${endpoint}`)
const restUrlTld = url.hostname.split('.').pop()
return restUrlTld
? `https://${ref}.supabase.${restUrlTld}/functions/v1/${functionName}`
: undefined
} catch (error) {
return undefined
}
}, [settings?.app_config?.endpoint, ref, functionName])
return `${protocol}://${endpoint}/functions/v1/${functionName}`
}, [settings?.app_config?.endpoint, settings?.app_config?.protocol, ref, functionName])
const deploymentDetailsUrl = useMemo(() => {
if (!ref || !functionName) return undefined
+89 -1
View File
@@ -1,6 +1,94 @@
import { describe, expect, it } from 'vitest'
import { isValidEdgeFunctionURL } from './edgeFunctions'
import {
buildDatabaseEdgeFunctionUrl,
isEdgeFunctionUrl,
isValidEdgeFunctionURL,
} from './edgeFunctions'
describe('buildDatabaseEdgeFunctionUrl', () => {
it('builds a platform edge function URL', () => {
expect(
buildDatabaseEdgeFunctionUrl(
'hello-world',
'uniquetwentychararef',
'https://uniquetwentychararef.supabase.red/rest/v1/',
true
)
).toBe('https://uniquetwentychararef.supabase.red/functions/v1/hello-world')
})
it('builds a self-hosted URL reachable from Postgres', () => {
expect(
buildDatabaseEdgeFunctionUrl(
'hello-world',
'default',
'http://localhost:8000/rest/v1/',
false
)
).toBe('http://kong:8000/functions/v1/hello-world')
})
})
describe('isEdgeFunctionUrl', () => {
it('matches platform edge function URLs for the current project', () => {
expect(
isEdgeFunctionUrl(
'https://uniquetwentychararef.supabase.co/functions/v1/hello-world',
'uniquetwentychararef',
'https://uniquetwentychararef.supabase.co/rest/v1/',
true
)
).toBe(true)
})
it('matches self-hosted edge function URLs', () => {
expect(
isEdgeFunctionUrl(
'http://kong:8000/functions/v1/hello-world',
'default',
'http://localhost:8000/rest/v1/',
false
)
).toBe(true)
})
it.each([
{
name: 'a project URL without the functions path',
url: 'https://uniquetwentychararef.supabase.co/rest/v1/hello-world',
projectRef: 'uniquetwentychararef',
restUrl: 'https://uniquetwentychararef.supabase.co/rest/v1/',
isPlatform: true,
},
{
name: "another project's edge function URL",
url: 'https://anotherprojectref000.supabase.co/functions/v1/hello-world',
projectRef: 'uniquetwentychararef',
restUrl: 'https://uniquetwentychararef.supabase.co/rest/v1/',
isPlatform: true,
},
{
name: 'a lookalike project origin',
url: 'https://uniquetwentychararef.supabase.example.com/functions/v1/hello-world',
projectRef: 'uniquetwentychararef',
restUrl: 'https://uniquetwentychararef.supabase.co/rest/v1/',
isPlatform: true,
},
{
name: 'a self-hosted non-function URL',
url: 'http://kong:8000/rest/v1/hello-world',
projectRef: 'default',
restUrl: 'http://localhost:8000/rest/v1/',
isPlatform: false,
},
])('does not match $name', ({ url, projectRef, restUrl, isPlatform }) => {
expect(
isEdgeFunctionUrl(url, projectRef, restUrl, isPlatform),
`Expected ${url} not to match an edge function URL`
).toBe(false)
})
})
describe('isValidEdgeFunctionURL', () => {
const validEdgeFunctionUrls = [
+35
View File
@@ -1,4 +1,39 @@
import { IS_PLATFORM } from '@/lib/constants'
const NIMBUS_PROD_PROJECTS_URL = process.env.NIMBUS_PROD_PROJECTS_URL
// Cron jobs and database hooks run inside Postgres, where Kong is available by this network alias.
const SELF_HOSTED_EDGE_FUNCTIONS_URL = 'http://kong:8000/functions/v1'
const PLATFORM_TLDS = ['co', 'red'] as const
export const buildDatabaseEdgeFunctionUrl = (
slug: string,
projectRef: string,
restUrl?: string,
isPlatform = IS_PLATFORM
) => {
if (!isPlatform) return `${SELF_HOSTED_EDGE_FUNCTIONS_URL}/${slug}`
const projectOrigin = restUrl ? new URL(restUrl).origin : `https://${projectRef}.supabase.co`
return `${projectOrigin}/functions/v1/${slug}`
}
export const isEdgeFunctionUrl = (
url: string,
projectRef: string,
restUrl?: string,
isPlatform = IS_PLATFORM
) => {
if (!isPlatform && url.startsWith(`${SELF_HOSTED_EDGE_FUNCTIONS_URL}/`)) return true
const projectOrigin = restUrl ? new URL(restUrl).origin : undefined
if (projectOrigin && url.startsWith(`${projectOrigin}/functions/v1/`)) return true
return PLATFORM_TLDS.some(
(tld) =>
url.startsWith(`https://${projectRef}.functions.supabase.${tld}/`) ||
url.startsWith(`https://${projectRef}.supabase.${tld}/functions/`)
)
}
export const isValidEdgeFunctionURL = (url: string, isPlatform: boolean) => {
if (NIMBUS_PROD_PROJECTS_URL !== undefined) {