import { PermissionAction } from '@supabase/shared-types/out/constants' import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js' import { IS_PLATFORM, useParams } from 'common' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { Clock, Download, FileArchive, Send } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import React, { useEffect, useState, type PropsWithChildren } from 'react' import { toast } from 'sonner' import { BreadcrumbItem_Shadcn_ as BreadcrumbItem, BreadcrumbLink_Shadcn_ as BreadcrumbLink, BreadcrumbList_Shadcn_ as BreadcrumbList, BreadcrumbSeparator_Shadcn_ as BreadcrumbSeparator, Button, HoverCard, HoverCardContent, HoverCardTrigger, NavMenu, NavMenuItem, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Separator, } from 'ui' import { TimestampInfo } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { PageHeader, PageHeaderAside, PageHeaderBreadcrumb, PageHeaderDescription, PageHeaderMeta, PageHeaderNavigationTabs, PageHeaderSummary, PageHeaderTitle, } from 'ui-patterns/PageHeader' import { ProjectLayout } from '../ProjectLayout' import EdgeFunctionsLayout from './EdgeFunctionsLayout' import { EdgeFunctionTesterSheet } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet' import CopyButton from '@/components/ui/CopyButton' import { DocsButton } from '@/components/ui/DocsButton' import NoPermission from '@/components/ui/NoPermission' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import { useEdgeFunctionBodyQuery } from '@/data/edge-functions/edge-function-body-query' import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query' import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { withAuth } from '@/hooks/misc/withAuth' import { DOCS_URL } from '@/lib/constants' dayjs.extend(relativeTime) interface EdgeFunctionDetailsLayoutProps { title: string } const EdgeFunctionDetailsLayout = ({ title, children, }: PropsWithChildren) => { const router = useRouter() const { data: org } = useSelectedOrganizationQuery() const { functionSlug, ref } = useParams() const { mutate: sendEvent } = useSendEventMutation() const { isLoading, can: canReadFunctions } = useAsyncCheckPermissions( PermissionAction.FUNCTIONS_READ, '*' ) const [isOpen, setIsOpen] = useState(false) const [isTimestampHoverCardOpen, setIsTimestampHoverCardOpen] = useState(false) const { data: selectedFunction, error, isError, } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug }) const { data: endpoint } = useProjectApiUrl({ projectRef: ref }) const { data: functionBody = { version: 0, files: [] }, error: filesError } = useEdgeFunctionBodyQuery( { projectRef: ref, slug: functionSlug, }, { retry: false, retryOnMount: true, refetchOnWindowFocus: false, staleTime: Infinity, refetchOnMount: false, refetchOnReconnect: false, refetchInterval: false, refetchIntervalInBackground: false, } ) const name = selectedFunction?.name || '' const functionUrl = endpoint && selectedFunction?.slug ? `${endpoint}/functions/v1/${selectedFunction.slug}` : '' const createdRelative = selectedFunction?.created_at ? dayjs(selectedFunction.created_at).fromNow() : undefined const updatedRelative = selectedFunction?.updated_at ? dayjs(selectedFunction.updated_at).fromNow() : undefined const browserTitle = { entity: functionSlug ? name || functionSlug : undefined, section: title, } const breadcrumbItems = [ { label: 'Edge Functions', href: `/project/${ref}/functions`, }, { label: functionSlug, href: `/project/${ref}/functions/${functionSlug}`, }, ] const navigationItems = functionSlug ? [ ...(IS_PLATFORM ? [ { label: 'Overview', href: `/project/${ref}/functions/${functionSlug}`, }, { label: 'Invocations', href: `/project/${ref}/functions/${functionSlug}/invocations`, }, { label: 'Logs', href: `/project/${ref}/functions/${functionSlug}/logs`, }, ] : []), { label: 'Code', href: `/project/${ref}/functions/${functionSlug}/code`, }, { label: 'Settings', href: `/project/${ref}/functions/${functionSlug}/details`, }, ] : [] const downloadFunction = async () => { if (filesError) return toast.error('Failed to retrieve edge function files') const zipFileWriter = new BlobWriter('application/zip') const zipWriter = new ZipWriter(zipFileWriter, { bufferedWrite: true }) // Extract file paths relative to function slug const filePaths = functionBody.files.map((file) => { const nameSections = file.name.split('/') const slugIndex = nameSections.indexOf(functionSlug ?? '') return nameSections.slice(slugIndex + 1).join('/') }) // Find the deepest relative path (count leading ../ segments) let maxDepth = 0 filePaths.forEach((path) => { const segments = path.split('/') let depth = 0 for (const segment of segments) { if (segment === '..') { depth++ } else { break } } maxDepth = Math.max(maxDepth, depth) }) // Add files to zip with normalized paths functionBody.files.forEach((file) => { const nameSections = file.name.split('/') const slugIndex = nameSections.indexOf(functionSlug ?? '') const fileName = nameSections.slice(slugIndex + 1).join('/') // Count and remove leading ../ segments const segments = fileName.split('/') let parentDirCount = 0 while (segments.length > 0 && segments[0] === '..') { segments.shift() parentDirCount++ } // Calculate safe path: // - Files without ../ go into the full base path // - Files with ../ go into a shallower path based on how many levels up they go const depthFromBase = maxDepth - parentDirCount const safePath = depthFromBase > 0 ? Array.from({ length: depthFromBase }, (_, i) => (i === 0 ? 'src' : `src${i}`)).join( '/' ) + '/' + segments.join('/') : segments.join('/') const fileBlob = new Blob([file.content]) zipWriter.add(safePath, new BlobReader(fileBlob)) }) const blobURL = URL.createObjectURL(await zipWriter.close()) const link = document.createElement('a') link.href = blobURL link.setAttribute('download', `${functionSlug}.zip`) document.body.appendChild(link) link.click() link.parentNode?.removeChild(link) } useEffect(() => { let cancel = false if (!!functionSlug && isError && error.code === 404 && !cancel) { toast('Edge function cannot be found in your project') router.push(`/project/${ref}/functions`) } return () => { cancel = true } }, [isError]) if (!isLoading && !canReadFunctions) { return ( ) } return (
{breadcrumbItems.length > 0 && ( {breadcrumbItems.map((item, index) => ( {item.href ? ( {item.label} ) : ( {item.label} )} {index < breadcrumbItems.length - 1 && } ))} )} {functionSlug ? name : 'Edge Functions'}
{functionUrl}
{createdRelative && (

Created

{!!selectedFunction && ( )}
)} {updatedRelative && (

Last deployed

{!!selectedFunction && ( )}
)} {selectedFunction?.version !== undefined && (

Deployments

{selectedFunction.version}

)}
{IS_PLATFORM && ( <>

Download via CLI

)}
{!!functionSlug && ( )}
{navigationItems.length > 0 && ( {navigationItems.map((item) => { const isActive = router.asPath.split('?')[0] === item.href return ( {item.label} ) })} )}
{children} setIsOpen(false)} />
) } export default withAuth(EdgeFunctionDetailsLayout)