feat(studio-local): functions management api - list functions (#40690)

## What is the current behaviour?

Functions page on self-hosted differs from Platform

## What is the new behaviour?

Adds the possibility to see a list of edge-functions in Self-Host
version.

## Summary by CodeRabbit

* **New Features**
* Added self-hosted edge functions support with filesystem-based
artifact listing and a new API endpoint to list local functions.
* Improved functions UI: unified view, enhanced search and adjacent sort
controls, and clearer local-hosting guidance.

* **Chores**
* Added environment configuration and docker volume for edge functions
management.
* Updated build/task config to pass through edge functions env and
include contentlayer outputs.

Co-authored-by: Ali Waseem <waseema393@gmail.com>
Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
Co-authored-by: Terry Sutton <saltcod@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-01-30 08:00:21 +08:00
1 parent edd58b9b82
commit 1862b71745
12 files changed
+253 -94

No files matched your search

+2
View File
@@ -26,3 +26,5 @@ NEXT_PUBLIC_SUPABASE_URL=https://xguihxuzqibwxjnimxev.supabase.co
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
# Required to manage edge-functions via dashboard on Self-Hosting
# EDGE_FUNCTIONS_MANAGEMENT_FOLDER=/path-where-edge-functions-are-mounted
@@ -3,11 +3,12 @@ import { Check, Copy } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { IS_PLATFORM } from 'common'
import { useParams } from 'common/hooks'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import type { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query'
import { copyToClipboard, TableCell, TableRow } from 'ui'
import { cn, copyToClipboard, TableCell, TableRow } from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { createNavigationHandler } from 'lib/navigation'
@@ -30,7 +31,9 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
? `https://${customDomainData.customDomain.hostname}/functions/v1/${item.slug}`
: `${protocol}://${endpoint}/functions/v1/${item.slug}`
const handleNavigation = createNavigationHandler(`/project/${ref}/functions/${item.slug}`, router)
const handleNavigation = IS_PLATFORM
? createNavigationHandler(`/project/${ref}/functions/${item.slug}`, router)
: undefined
return (
<TableRow
@@ -39,7 +42,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
onAuxClick={handleNavigation}
onKeyDown={handleNavigation}
tabIndex={0}
className="cursor-pointer inset-focus"
className={cn({ 'cursor-pointer inset-focus': IS_PLATFORM })}
>
<TableCell>
<p className="text-sm text-foreground whitespace-nowrap py-2">{item.name}</p>
@@ -189,7 +189,7 @@ export const FunctionsEmptyState = () => {
)
}
export const FunctionsEmptyStateLocal = () => {
export const FunctionsInstructionsLocal = () => {
const showStripeExample = useIsFeatureEnabled('edge_functions:show_stripe_example')
const templates = useMemo(() => {
if (showStripeExample) {
@@ -37,6 +37,6 @@ export const useEdgeFunctionsQuery = <TData = EdgeFunctionsData>(
useQuery<EdgeFunctionsData, EdgeFunctionsError, TData>({
queryKey: edgeFunctionsKeys.list(projectRef),
queryFn: ({ signal }) => getEdgeFunctions({ projectRef }, signal),
enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined',
enabled: enabled && typeof projectRef !== 'undefined',
...options,
})
@@ -0,0 +1,41 @@
import type { Dirent } from 'node:fs'
import { readdir, stat } from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { FunctionArtifact } from './types'
export class FileSystemFunctionsArtifactStore {
constructor(private folderPath: string) {}
async getFunctions(): Promise<FunctionArtifact[]> {
const dirEntries = await readdir(this.folderPath, { withFileTypes: true })
const functionsFolders = dirEntries.filter((dir) => dir.isDirectory() && dir.name !== 'main')
const functionsArtifacts = await Promise.all(
functionsFolders.map(parseFolderToFunctionArtifact)
)
return functionsArtifacts.filter((f) => f !== undefined)
}
}
async function parseFolderToFunctionArtifact(
folder: Dirent
): Promise<FunctionArtifact | undefined> {
const folderPath = path.join(folder.parentPath, folder.name)
const files = await readdir(folderPath, { withFileTypes: true })
const entrypoint = files.find((file) => file.isFile() && file.name.startsWith('index'))
if (!entrypoint) return
const entrypointPath = path.join(folderPath, entrypoint.name)
const entrypointStat = await stat(entrypointPath)
return {
slug: folder.name,
entrypoint_path: pathToFileURL(entrypointPath).href,
created_at: entrypointStat.birthtimeMs,
updated_at: entrypointStat.mtimeMs,
}
}
@@ -0,0 +1,14 @@
import assert from 'node:assert'
import { FileSystemFunctionsArtifactStore } from './fileSystemStore'
import { assertSelfHosted } from '../util'
export function getFunctionsArtifactStore() {
assertSelfHosted()
assert(
process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER,
'EDGE_FUNCTIONS_MANAGEMENT_FOLDER is required'
)
return new FileSystemFunctionsArtifactStore(process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER)
}
@@ -0,0 +1,6 @@
export type FunctionArtifact = {
slug: string
entrypoint_path: string
created_at: number
updated_at: number
}
@@ -0,0 +1,47 @@
import { type NextApiRequest, type NextApiResponse } from 'next'
import type { components } from 'api-types'
import { uuidv4 } from 'lib/helpers'
import apiWrapper from 'lib/api/apiWrapper'
import { getFunctionsArtifactStore } from 'lib/api/self-hosted/functions'
export default function handlerWithErrorCatching(req: NextApiRequest, res: NextApiResponse) {
return apiWrapper(req, res, handler, { withAuth: true })
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
switch (method) {
case 'GET':
return handleGetAll(req, res)
default:
res.setHeader('Allow', ['GET'])
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
}
}
type EdgeFunctionsResponse = components['schemas']['FunctionResponse']
const handleGetAll = async (_req: NextApiRequest, res: NextApiResponse) => {
const store = getFunctionsArtifactStore()
const functionsArtifacts = await store.getFunctions()
if (functionsArtifacts.length === 0) return res.status(200).json([])
const functions = functionsArtifacts.map(
(func) =>
({
id: uuidv4(),
slug: func.slug,
version: 1,
name: func.slug,
status: 'ACTIVE',
entrypoint_path: func.entrypoint_path,
created_at: func.created_at,
updated_at: func.updated_at,
}) satisfies EdgeFunctionsResponse
)
return res.status(200).json(functions)
}
@@ -14,7 +14,7 @@ import {
import { EdgeFunctionsListItem } from 'components/interfaces/Functions/EdgeFunctionsListItem'
import {
FunctionsEmptyState,
FunctionsEmptyStateLocal,
FunctionsInstructionsLocal,
} from 'components/interfaces/Functions/FunctionsEmptyState'
import { TerminalInstructionsDialog } from 'components/interfaces/Functions/TerminalInstructionsDialog'
import DefaultLayout from 'components/layouts/DefaultLayout'
@@ -25,6 +25,7 @@ import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
import { Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
import { Admonition } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
@@ -84,84 +85,90 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
<PageContainer size="large">
<PageSection>
<PageSectionContent>
{IS_PLATFORM ? (
<>
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
{isSuccess && (
<>
{hasFunctions ? (
<div className="space-y-4">
<div className="flex flex-col gap-6">
{isLoading && <GenericSkeletonLoader />}
{isError &&
(IS_PLATFORM ? (
<AlertError error={error} subject="Failed to retrieve edge functions" />
) : (
<Admonition type="warning" title="Failed to retrieve edge functions">
<p className="prose [&>code]:text-xs text-sm">
Local functions can be found at <code>supabase/functions</code> folder.
</p>
</Admonition>
))}
{isSuccess && (
<>
{hasFunctions ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<div className="relative">
<Input
placeholder="Search function names"
icon={<Search />}
size="tiny"
className="w-32 md:w-64"
value={search}
onChange={(event) => setSearch(event.target.value)}
actions={[
search && (
<Button
size="tiny"
type="text"
icon={<X />}
onClick={() => setSearch('')}
className="p-0 h-5 w-5"
/>
),
]}
/>
</div>
</div>
<div className="flex items-center gap-2">
<EdgeFunctionsSortDropdown value={sort} onChange={setSortQueryParam} />
<div className="relative">
<Input
placeholder="Search function names"
icon={<Search />}
size="tiny"
className="w-32 md:w-64"
value={search}
onChange={(event) => setSearch(event.target.value)}
actions={[
search && (
<Button
size="tiny"
type="text"
icon={<X />}
onClick={() => setSearch('')}
className="p-0 h-5 w-5"
/>
),
]}
/>
</div>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>URL</TableHead>
<TableHead className="hidden 2xl:table-cell">Created</TableHead>
<TableHead className="lg:table-cell">Updated</TableHead>
<TableHead className="lg:table-cell">Deployments</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
{filteredFunctions.length > 0 ? (
filteredFunctions.map((item) => (
<EdgeFunctionsListItem key={item.id} function={item} />
))
) : (
<TableRow>
<TableCell colSpan={5}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{search}" did not return any results
</p>
</TableCell>
</TableRow>
)}
</>
</TableBody>
</Table>
</Card>
<div className="flex items-center gap-2">
<EdgeFunctionsSortDropdown value={sort} onChange={setSortQueryParam} />
</div>
</div>
) : (
<FunctionsEmptyState />
)}
</>
)}
</>
) : (
<FunctionsEmptyStateLocal />
)}
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>URL</TableHead>
<TableHead className="hidden 2xl:table-cell">Created</TableHead>
<TableHead className="lg:table-cell">Updated</TableHead>
<TableHead className="lg:table-cell">Deployments</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<>
{filteredFunctions.length > 0 ? (
filteredFunctions.map((item) => (
<EdgeFunctionsListItem key={item.id} function={item} />
))
) : (
<TableRow>
<TableCell colSpan={5}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{search}" did not return any results
</p>
</TableCell>
</TableRow>
)}
</>
</TableBody>
</Table>
</Card>
</div>
) : (
<FunctionsEmptyState />
)}
</>
)}
{!IS_PLATFORM && <FunctionsInstructionsLocal />}
</div>
</PageSectionContent>
</PageSection>
</PageContainer>
+2
View File
@@ -60,8 +60,10 @@ services:
# Uncomment to use Big Query backend for analytics
# NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery
SNIPPETS_MANAGEMENT_FOLDER: /app/snippets
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions
volumes:
- ./volumes/snippets:/app/snippets:Z
- ./volumes/functions:/app/edge-functions:Z
kong:
container_name: supabase-kong
+1
View File
@@ -31,6 +31,7 @@ const defaultEnv = {
NEXT_PUBLIC_HCAPTCHA_SITE_KEY: '10000000-ffff-ffff-ffff-000000000001',
NEXT_PUBLIC_NODE_ENV: 'test',
SNIPPETS_MANAGEMENT_FOLDER: '../../supabase/snippets',
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: '../../supabase/functions' // path relative to studio project
}
const environment = { ...generatedEnv, ...defaultEnv }
+50 -14
View File
@@ -3,11 +3,18 @@
"ui": "stream",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
"dependsOn": [
"^build"
],
"outputs": [
"dist/**",
".next/**"
]
},
"cms#build": {
"dependsOn": ["^build"],
"dependsOn": [
"^build"
],
"env": [
"BLOG_APP_URL",
"CRON_SECRET",
@@ -25,10 +32,15 @@
"S3_ENDPOINT",
"VERCEL_PROJECT_PRODUCTION_URL"
],
"outputs": [".next/**", "!.next/cache/**"]
"outputs": [
".next/**",
"!.next/cache/**"
]
},
"design-system#build": {
"dependsOn": ["^build"],
"dependsOn": [
"^build"
],
"env": [
"NEXT_PUBLIC_BASE_PATH",
"NEXT_PUBLIC_APP_URL",
@@ -41,10 +53,16 @@
"NEXT_PUBLIC_GOTRUE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY"
],
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
"outputs": [
".next/**",
"!.next/cache/**",
".contentlayer/**"
]
},
"studio#build": {
"dependsOn": ["^build"],
"dependsOn": [
"^build"
],
"env": [
"ANALYZE",
"NEXT_PUBLIC_SUPPORT_API_URL",
@@ -133,12 +151,18 @@
"passThroughEnv": [
"CURRENT_CLI_VERSION",
"VERCEL_GIT_COMMIT_SHA",
"SNIPPETS_MANAGEMENT_FOLDER"
"SNIPPETS_MANAGEMENT_FOLDER",
"EDGE_FUNCTIONS_MANAGEMENT_FOLDER"
],
"outputs": [".next/**", "!.next/cache/**"]
"outputs": [
".next/**",
"!.next/cache/**"
]
},
"www#build": {
"dependsOn": ["^build"],
"dependsOn": [
"^build"
],
"env": [
"ANALYZE",
"CI",
@@ -210,10 +234,16 @@
"CUSTOMERIO_API_KEY",
"CUSTOMERIO_APP_API_KEY"
],
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
"outputs": [
".next/**",
"!.next/cache/**",
".contentlayer/**"
]
},
"ui-library#build": {
"dependsOn": ["^build"],
"dependsOn": [
"^build"
],
"env": [
"NEXT_PUBLIC_BASE_PATH",
"NEXT_PUBLIC_APP_URL",
@@ -235,7 +265,11 @@
"SUPABASE_MANAGEMENT_API_TOKEN",
"OPENAI_API_KEY"
],
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
"outputs": [
".next/**",
"!.next/cache/**",
".contentlayer/**"
]
},
"lint": {
"outputs": []
@@ -251,7 +285,9 @@
"cache": false
},
"typecheck": {
"dependsOn": ["^typecheck"],
"dependsOn": [
"^typecheck"
],
"outputs": []
}
}