From 1862b717458b25b794d214c5f18a349f69ed5669 Mon Sep 17 00:00:00 2001 From: Kalleby Santos <105971119+kallebysantos@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:00:21 +0000 Subject: [PATCH] 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 Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> Co-authored-by: Terry Sutton --- apps/studio/.env | 2 + .../Functions/EdgeFunctionsListItem.tsx | 9 +- .../Functions/FunctionsEmptyState.tsx | 2 +- .../edge-functions/edge-functions-query.ts | 2 +- .../self-hosted/functions/fileSystemStore.ts | 41 +++++ .../lib/api/self-hosted/functions/index.ts | 14 ++ .../lib/api/self-hosted/functions/types.ts | 6 + .../api/v1/projects/[ref]/functions/index.ts | 47 ++++++ .../pages/project/[ref]/functions/index.tsx | 157 +++++++++--------- docker/docker-compose.yml | 2 + scripts/generateLocalEnv.js | 1 + turbo.json | 64 +++++-- 12 files changed, 253 insertions(+), 94 deletions(-) create mode 100644 apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts create mode 100644 apps/studio/lib/api/self-hosted/functions/index.ts create mode 100644 apps/studio/lib/api/self-hosted/functions/types.ts create mode 100644 apps/studio/pages/api/v1/projects/[ref]/functions/index.ts diff --git a/apps/studio/.env b/apps/studio/.env index bea99a96c27..580824a0180 100644 --- a/apps/studio/.env +++ b/apps/studio/.env @@ -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 diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx index d3407ba1e32..c57fdbda3b2 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx @@ -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 (

{item.name}

diff --git a/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx b/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx index caee9cf88b8..86c8223c9a2 100644 --- a/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx +++ b/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx @@ -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) { diff --git a/apps/studio/data/edge-functions/edge-functions-query.ts b/apps/studio/data/edge-functions/edge-functions-query.ts index 798f3374763..ea788f06764 100644 --- a/apps/studio/data/edge-functions/edge-functions-query.ts +++ b/apps/studio/data/edge-functions/edge-functions-query.ts @@ -37,6 +37,6 @@ export const useEdgeFunctionsQuery = ( useQuery({ queryKey: edgeFunctionsKeys.list(projectRef), queryFn: ({ signal }) => getEdgeFunctions({ projectRef }, signal), - enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined', + enabled: enabled && typeof projectRef !== 'undefined', ...options, }) diff --git a/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts b/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts new file mode 100644 index 00000000000..38885fe6429 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts @@ -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 { + 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 { + 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, + } +} diff --git a/apps/studio/lib/api/self-hosted/functions/index.ts b/apps/studio/lib/api/self-hosted/functions/index.ts new file mode 100644 index 00000000000..9b4ec62cdd1 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/functions/index.ts @@ -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) +} diff --git a/apps/studio/lib/api/self-hosted/functions/types.ts b/apps/studio/lib/api/self-hosted/functions/types.ts new file mode 100644 index 00000000000..0a124c69669 --- /dev/null +++ b/apps/studio/lib/api/self-hosted/functions/types.ts @@ -0,0 +1,6 @@ +export type FunctionArtifact = { + slug: string + entrypoint_path: string + created_at: number + updated_at: number +} diff --git a/apps/studio/pages/api/v1/projects/[ref]/functions/index.ts b/apps/studio/pages/api/v1/projects/[ref]/functions/index.ts new file mode 100644 index 00000000000..2c38ebe4f2c --- /dev/null +++ b/apps/studio/pages/api/v1/projects/[ref]/functions/index.ts @@ -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) +} diff --git a/apps/studio/pages/project/[ref]/functions/index.tsx b/apps/studio/pages/project/[ref]/functions/index.tsx index 1e97837f39d..ff03b3a7f78 100644 --- a/apps/studio/pages/project/[ref]/functions/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/index.tsx @@ -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 = () => { - {IS_PLATFORM ? ( - <> - {isLoading && } - {isError && } - {isSuccess && ( - <> - {hasFunctions ? ( -
+
+ {isLoading && } + {isError && + (IS_PLATFORM ? ( + + ) : ( + +

+ Local functions can be found at supabase/functions folder. +

+
+ ))} + {isSuccess && ( + <> + {hasFunctions ? ( +
+
-
-
- } - size="tiny" - className="w-32 md:w-64" - value={search} - onChange={(event) => setSearch(event.target.value)} - actions={[ - search && ( -
-
-
- +
+ } + size="tiny" + className="w-32 md:w-64" + value={search} + onChange={(event) => setSearch(event.target.value)} + actions={[ + search && ( +
- - - - - Name - URL - Created - Updated - Deployments - - - - - <> - {filteredFunctions.length > 0 ? ( - filteredFunctions.map((item) => ( - - )) - ) : ( - - -

No results found

-

- Your search for "{search}" did not return any results -

-
-
- )} - -
-
-
+
+ +
- ) : ( - - )} - - )} - - ) : ( - - )} + + + + + Name + URL + Created + Updated + Deployments + + + + + <> + {filteredFunctions.length > 0 ? ( + filteredFunctions.map((item) => ( + + )) + ) : ( + + +

No results found

+

+ Your search for "{search}" did not return any results +

+
+
+ )} + +
+
+
+
+ ) : ( + + )} + + )} + {!IS_PLATFORM && } +
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 67f64fdc414..e791e5b4f29 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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 diff --git a/scripts/generateLocalEnv.js b/scripts/generateLocalEnv.js index b6fb6bd79ac..808f964d89d 100644 --- a/scripts/generateLocalEnv.js +++ b/scripts/generateLocalEnv.js @@ -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 } diff --git a/turbo.json b/turbo.json index eabf2b1f0ad..693483684b0 100644 --- a/turbo.json +++ b/turbo.json @@ -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": [] } }