* fix: add jsr:@std/path module * fix: use relative paths for files in editor * Smol fix --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
242 lines
8.9 KiB
TypeScript
242 lines
8.9 KiB
TypeScript
import { common, dirname, relative } from '@std/path/posix'
|
|
import { AlertCircle, CornerDownLeft, Loader2 } from 'lucide-react'
|
|
import { useRouter } from 'next/router'
|
|
import { useEffect, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
|
|
import LogoLoader from '@ui/components/LogoLoader'
|
|
import { useParams } from 'common'
|
|
import DefaultLayout from 'components/layouts/DefaultLayout'
|
|
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
|
import FileExplorerAndEditor from 'components/ui/FileExplorerAndEditor/FileExplorerAndEditor'
|
|
import { useEdgeFunctionBodyQuery } from 'data/edge-functions/edge-function-body-query'
|
|
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
|
|
import { useEdgeFunctionDeployMutation } from 'data/edge-functions/edge-functions-deploy-mutation'
|
|
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
|
import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
|
|
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
|
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
|
|
import { useFlag } from 'hooks/ui/useFlag'
|
|
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
|
|
import { Button } from 'ui'
|
|
|
|
const CodePage = () => {
|
|
const router = useRouter()
|
|
const { ref, functionSlug } = useParams()
|
|
const project = useSelectedProject()
|
|
const isOptedInToAI = useOrgOptedIntoAi()
|
|
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
|
|
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
|
|
const { mutate: sendEvent } = useSendEventMutation()
|
|
const org = useSelectedOrganization()
|
|
|
|
const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
|
|
const {
|
|
data: functionFiles,
|
|
isLoading: isLoadingFiles,
|
|
isError: isErrorLoadingFiles,
|
|
isSuccess: isSuccessLoadingFiles,
|
|
error: filesError,
|
|
} = useEdgeFunctionBodyQuery({
|
|
projectRef: ref,
|
|
slug: functionSlug,
|
|
})
|
|
const [files, setFiles] = useState<
|
|
{ id: number; name: string; content: string; selected?: boolean }[]
|
|
>([])
|
|
|
|
const { mutateAsync: deployFunction, isLoading: isDeploying } = useEdgeFunctionDeployMutation({
|
|
onSuccess: () => {
|
|
toast.success('Successfully updated edge function')
|
|
},
|
|
})
|
|
|
|
const onUpdate = async () => {
|
|
if (isDeploying || !ref || !functionSlug || !selectedFunction || files.length === 0) return
|
|
|
|
try {
|
|
const newEntrypointPath = selectedFunction.entrypoint_path?.split('/').pop()
|
|
const newImportMapPath = selectedFunction.import_map_path?.split('/').pop()
|
|
|
|
const fallbackEntrypointPath = () => {
|
|
// when there's no matching entrypoint path is set,
|
|
// we use few heuristics to find an entrypoint file
|
|
// 1. If the function has only a single TS / JS file, if so set it as entrypoint
|
|
const jsFiles = files.filter(({ name }) => name.endsWith('.js') || name.endsWith('.ts'))
|
|
if (jsFiles.length === 1) {
|
|
return jsFiles[0].name
|
|
} else if (jsFiles.length) {
|
|
// 2. If function has a `index` or `main` file use it as the entrypoint
|
|
const regex = /^.*?(index|main).*$/i
|
|
const matchingFile = jsFiles.find(({ name }) => regex.test(name))
|
|
// 3. if no valid index / main file found, we set the entrypoint expliclty to first JS file
|
|
return matchingFile ? matchingFile.name : jsFiles[0].name
|
|
} else {
|
|
// no potential entrypoint files found, this will most likely result in an error on deploy
|
|
return 'index.ts'
|
|
}
|
|
}
|
|
|
|
const fallbackImportMapPath = () => {
|
|
// try to find a deno.json or import_map.json file
|
|
const regex = /^.*?(deno|import_map).json*$/i
|
|
return files.find(({ name }) => regex.test(name))?.name
|
|
}
|
|
|
|
await deployFunction({
|
|
projectRef: ref,
|
|
slug: selectedFunction.slug,
|
|
metadata: {
|
|
name: selectedFunction.name,
|
|
verify_jwt: selectedFunction.verify_jwt,
|
|
entrypoint_path: files.some(({ name }) => name === newEntrypointPath)
|
|
? (newEntrypointPath as string)
|
|
: fallbackEntrypointPath(),
|
|
import_map_path: files.some(({ name }) => name === newImportMapPath)
|
|
? newImportMapPath
|
|
: fallbackImportMapPath(),
|
|
},
|
|
files: files.map(({ name, content }) => ({ name, content })),
|
|
})
|
|
} catch (error) {
|
|
toast.error(
|
|
`Failed to update function: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
)
|
|
}
|
|
}
|
|
|
|
function getBasePath(entrypoint: string | undefined): string {
|
|
if (!entrypoint) {
|
|
return '/'
|
|
}
|
|
|
|
try {
|
|
return dirname(new URL(entrypoint).pathname)
|
|
} catch (e) {
|
|
console.error('failed to parse entrypoint', entrypoint)
|
|
return '/'
|
|
}
|
|
}
|
|
|
|
// TODO (Saxon): Remove this once the flag is fully launched
|
|
useEffect(() => {
|
|
if (edgeFunctionCreate !== undefined && !edgeFunctionCreate) {
|
|
router.push(`/project/${ref}/functions`)
|
|
}
|
|
}, [edgeFunctionCreate])
|
|
|
|
useEffect(() => {
|
|
// Set files from API response when available
|
|
if (selectedFunction?.entrypoint_path && functionFiles) {
|
|
const base_path = getBasePath(selectedFunction?.entrypoint_path)
|
|
const filesWithRelPath = functionFiles
|
|
// ignore empty filesq
|
|
.filter((file: { name: string; content: string }) => !!file.content.length)
|
|
// set file paths relative to entrypoint
|
|
.map((file: { name: string; content: string }) => {
|
|
try {
|
|
// if the current file and base path doesn't share a common path,
|
|
// return unmodified file
|
|
const common_path = common([base_path, file.name])
|
|
if (common_path === '' || common_path === '/tmp/') {
|
|
return file
|
|
}
|
|
|
|
file.name = relative(base_path, file.name)
|
|
return file
|
|
} catch (e) {
|
|
console.error(e)
|
|
// return unmodified file
|
|
return file
|
|
}
|
|
})
|
|
|
|
setFiles((prev) => {
|
|
return filesWithRelPath.map((file: { name: string; content: string }, index: number) => {
|
|
const prevState = prev.find((x) => x.name === file.name)
|
|
return {
|
|
id: index + 1,
|
|
name: file.name,
|
|
content: file.content,
|
|
selected: prevState?.selected ?? index === 0,
|
|
}
|
|
})
|
|
})
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [functionFiles])
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{isLoadingFiles && (
|
|
<div className="flex flex-col items-center justify-center h-full bg-surface-200">
|
|
<LogoLoader />
|
|
</div>
|
|
)}
|
|
|
|
{isErrorLoadingFiles && (
|
|
<div className="flex flex-col items-center justify-center h-full bg-surface-200">
|
|
<div className="flex flex-col items-center text-center gap-2 max-w-md">
|
|
<AlertCircle size={24} strokeWidth={1.5} className="text-amber-900" />
|
|
<h3 className="text-md mt-4">Failed to load function code</h3>
|
|
<p className="text-sm text-foreground-light">
|
|
{filesError?.message ||
|
|
'There was an error loading the function code. The format may be invalid or the function may be corrupted.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isSuccessLoadingFiles && (
|
|
<>
|
|
<FileExplorerAndEditor
|
|
files={files}
|
|
onFilesChange={setFiles}
|
|
aiEndpoint={`${BASE_PATH}/api/ai/edge-function/complete`}
|
|
aiMetadata={{
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
includeSchemaMetadata,
|
|
}}
|
|
/>
|
|
<div className="flex items-center bg-background-muted justify-end p-4 border-t bg-surface-100 shrink-0">
|
|
<Button
|
|
loading={isDeploying}
|
|
size="medium"
|
|
disabled={files.length === 0 || isLoadingFiles}
|
|
onClick={() => {
|
|
onUpdate()
|
|
sendEvent({
|
|
action: 'edge_function_deploy_updates_button_clicked',
|
|
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
|
})
|
|
}}
|
|
iconRight={
|
|
isDeploying ? (
|
|
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
|
|
) : (
|
|
<div className="flex items-center space-x-1">
|
|
<CornerDownLeft size={10} strokeWidth={1.5} />
|
|
</div>
|
|
)
|
|
}
|
|
>
|
|
Deploy updates
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
CodePage.getLayout = (page: React.ReactNode) => {
|
|
return (
|
|
<DefaultLayout>
|
|
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
|
|
</DefaultLayout>
|
|
)
|
|
}
|
|
|
|
export default CodePage
|