chore: safely handles bad network requests

This commit is contained in:
PatrikKozak
2023-09-21 15:34:10 -04:00
parent f2332bc7c9
commit 6e99990a9b
9 changed files with 292 additions and 77 deletions

View File

@@ -2,6 +2,11 @@ MONGODB_URI=mongodb://127.0.0.1/payload-example-custom-server
PAYLOAD_SECRET=PAYLOAD_CUSTOM_SERVER_EXAMPLE_SECRET_KEY
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
PAYLOAD_PUBLIC_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET
COOKIE_DOMAIN=localhost
REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY
ENABLE_PAYLOAD_CLOUD=false
COOKIE_DOMAIN=localhost
# Next.js vars
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
NEXT_PUBLIC_IS_LIVE=
NEXT_PRIVATE_DRAFT_SECRET=EXAMPLE_DRAFT_SECRET
NEXT_PRIVATE_REVALIDATION_KEY=EXAMPLE_REVALIDATION_KEY

View File

@@ -1,18 +1,11 @@
require('dotenv').config()
const imageDomains = ['localhost']
if (process.env.PAYLOAD_PUBLIC_SERVER_URL) {
const { hostname } = new URL(process.env.PAYLOAD_PUBLIC_SERVER_URL)
imageDomains.push(hostname)
}
// eslint-disable-next-line no-console
console.log('allowed image domains:', imageDomains.join(', '))
module.exports = {
reactStrictMode: true,
swcMinify: true,
images: {
domains: imageDomains,
domains: ['localhost', process.env.NEXT_PUBLIC_SERVER_URL]
.filter(Boolean)
.map(url => url.replace(/https?:\/\//, '')),
},
}

View File

@@ -1,11 +1,20 @@
import { Metadata, ResolvingMetadata } from 'next'
import { notFound } from 'next/navigation'
import { Media } from '../../payload/payload-types'
import { Media, Page } from '../../payload/payload-types'
import { staticHome } from '../../payload/seed/home-static'
import { ContentLayout } from '../_components/content/contentLayout'
import { fetchPage } from '../_utils/api'
import { fetchPage, fetchPages } from '../_utils/api'
import { parsePreviewOptions } from '../_utils/preview'
// Payload Cloud caches all files through Cloudflare, so we don't need Next.js to cache them as well
// This means that we can turn off Next.js data caching and instead rely solely on the Cloudflare CDN
// To do this, we include the `no-cache` header on the fetch requests used to get the data for this page
// But we also need to force Next.js to dynamically render this page on each request for preview mode to work
// See https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic
// If you are not using Payload Cloud then this line can be removed, see `../../../README.md#cache`
export const dynamic = 'force-dynamic'
interface LandingPageProps {
params: {
slug: string
@@ -13,21 +22,75 @@ interface LandingPageProps {
searchParams: Record<string, string>
}
export default async function LandingPage({ params, searchParams }: LandingPageProps) {
const { slug } = params
const options = parsePreviewOptions(searchParams)
let page: Page | null = null
try {
page = await fetchPage(slug, options)
} catch (error) {
// when deploying this template on Payload Cloud, this page needs to build before the APIs are live
// so swallow the error here and simply render the page with fallback data where necessary
// in production you may want to redirect to a 404 page or at least log the error somewhere
// console.error(error)
}
// if no `home` page exists, render a static one using dummy content
// you should delete this code once you have a home page in the CMS
// this is really only useful for those who are demoing this template
if (!page) {
page = staticHome
}
if (!page) {
return notFound()
}
return <ContentLayout layout={page.layout} className="mt-16" />
}
export async function generateStaticParams() {
try {
const pages = await fetchPages()
return pages.map(({ slug }) => ({ params: { slug } }))
} catch (error) {
return []
}
}
export async function generateMetadata(
{ searchParams, params }: LandingPageProps,
{ params, searchParams }: LandingPageProps,
parent?: ResolvingMetadata,
): Promise<Metadata> {
const defaultTitle = (await parent)?.title?.absolute
const { slug } = params
const options = parsePreviewOptions(searchParams)
const page = await fetchPage(params.slug, options)
let page: Page | null = null
try {
page = await fetchPage(slug, options)
} catch (error) {
// don't throw an error if the fetch fails
// this is so that we can render a static home page for the demo
// when deploying this template on Payload Cloud, this page needs to build before the APIs are live
// in production you may want to redirect to a 404 page or at least log the error somewhere
}
const defaultTitle = (await parent)?.title?.absolute
const title = page?.meta?.title || defaultTitle
const description = page?.meta?.description || 'A portfolio of work by a digital professional.'
const images = []
if (page?.meta?.image) {
images.push((page.meta.image as Media).url)
}
if (!page) {
page = staticHome
}
return {
title,
description,
@@ -38,17 +101,3 @@ export async function generateMetadata(
},
}
}
const LandingPage = async ({ params, searchParams }: LandingPageProps) => {
const { slug } = params
const options = parsePreviewOptions(searchParams)
const page = await fetchPage(slug, options)
if (!page?.layout) {
notFound()
}
return <ContentLayout layout={page.layout} className="mt-16" />
}
export default LandingPage

View File

@@ -4,7 +4,6 @@ import { useForm } from 'react-hook-form'
import { Data } from 'payload/dist/admin/components/forms/Form/types'
import { Form as FormTypes } from '../../../payload/payload-types'
import { serverUrl } from '../../_utils/api'
import { Block } from '../ui/block'
import { Button } from '../ui/button'
import { Dialog, DialogContent } from '../ui/dialog'
@@ -92,7 +91,7 @@ export const FormBlock: FC<FormBlockProps> = props => {
}))
try {
const req = await fetch(`${serverUrl}/api/form-submissions`, {
const req = await fetch(`${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/form-submissions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json', // Correct typo: "Content-Types" to "Content-Type"

View File

@@ -1,8 +1,6 @@
import type { Header, Page, Profile, Project } from '../../payload/payload-types'
import type { DraftOptions } from './preview'
export const serverUrl = process.env.PAYLOAD_PUBLIC_SERVER_URL ?? 'http://localhost:3000'
const initPreviewRequest = (init: RequestInit, qs: URLSearchParams, token: string): void => {
if (!token) {
throw new Error('No token provided when attempting to preview content')
@@ -16,12 +14,15 @@ const initPreviewRequest = (init: RequestInit, qs: URLSearchParams, token: strin
}
export const fetchProfile = async (): Promise<Profile> => {
const url = `${serverUrl}/api/globals/profile?locale=en`
if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found')
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/globals/profile?locale=en`
const profile: Profile = await fetch(url, {
cache: 'force-cache',
next: { tags: ['global.profile'] },
}).then(res => {
if (!res.ok) throw new Error('Error fetching profile doc')
return res.json()
})
@@ -29,11 +30,16 @@ export const fetchProfile = async (): Promise<Profile> => {
}
export const fetchHeader = async (): Promise<Header> => {
const url = `${serverUrl}/api/globals/header?locale=en`
if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found')
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/globals/header?locale=en`
const header: Header = await fetch(url, {
cache: 'force-cache',
next: { tags: ['global.header'] },
}).then(res => res.json())
}).then(res => {
if (!res.ok) throw new Error('Error fetching header doc')
return res.json()
})
return header
}
@@ -44,15 +50,20 @@ export const fetchPage = async (
slug: string,
options: FetchPageOptions,
): Promise<Page | undefined> => {
if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found')
const qs = new URLSearchParams({ 'where[slug][equals]': slug })
const init: RequestInit = { next: { tags: [`pages/${slug}`] } }
if (options.draft) {
initPreviewRequest(init, qs, options.payloadToken)
}
const url = `${serverUrl}/api/pages?${qs.toString()}`
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/pages?${qs.toString()}`
const page: Page = await fetch(url, init)
.then(res => res.json())
.then(res => {
if (!res.ok) throw new Error('Error fetching page doc')
return res.json()
})
.then(res => res?.docs?.[0])
return page
@@ -64,6 +75,8 @@ export const fetchProject = async (
slug: string,
options: FetchProjectOptions,
): Promise<Project> => {
if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found')
const qs = new URLSearchParams(`where[slug][equals]=${slug}`)
const init: RequestInit = {}
@@ -73,10 +86,56 @@ export const fetchProject = async (
init.next = { tags: [`projects/${slug}`] }
}
const url = `${serverUrl}/api/projects?${qs.toString()}`
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/projects?${qs.toString()}`
const project: Project = await fetch(url, init)
.then(res => res.json())
.then(res => {
if (!res.ok) throw new Error('Error fetching project doc')
return res.json()
})
.then(res => res?.docs?.[0])
return project
}
export const fetchPages = async (options?: FetchPageOptions): Promise<Page[]> => {
if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found')
const qs = new URLSearchParams()
const init: RequestInit = { next: { tags: ['pages'] } }
if (options?.draft) {
initPreviewRequest(init, qs, options.payloadToken)
}
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/pages?${qs.toString()}`
const pages: Page[] = await fetch(url, init)
.then(res => {
if (!res.ok) throw new Error('Error fetching pages doc')
return res.json()
})
.then(res => res?.docs || [])
return pages
}
export const fetchProjects = async (options?: FetchProjectOptions): Promise<Project[]> => {
if (!process.env.PAYLOAD_PUBLIC_SERVER_URL) throw new Error('PAYLOAD_PUBLIC_SERVER_URL not found')
const init: RequestInit = {}
if (options.draft) {
initPreviewRequest(init, undefined, options.payloadToken)
} else {
init.next = { tags: ['projects'] }
}
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/projects`
const projects: Project[] = await fetch(url, init)
.then(res => {
if (!res.ok) throw new Error('Error fetching projects doc')
return res.json()
})
.then(res => res?.docs || [])
return projects
}

View File

@@ -3,26 +3,36 @@ import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
import { Header, Profile } from '../payload/payload-types'
import { Footer } from './_components/siteLayout/footer'
import { NavBar } from './_components/siteLayout/navBar'
import { Backdrop } from './_components/ui/backdrop/backdrop'
import { ThemeProvider } from './_provider/themeProvider'
import { fetchHeader, fetchProfile, serverUrl } from './_utils/api'
import { fetchHeader, fetchProfile } from './_utils/api'
import './globals.css'
export async function generateMetadata() {
const profile = await fetchProfile()
let profile: Profile | null = null
try {
profile = await fetchProfile()
} catch (error) {}
return {
metadataBase: new URL(serverUrl),
metadataBase: new URL(process.env.NEXT_PUBLIC_SERVER_URL || 'https://payloadcms.com'),
title: `Portfolio | ${profile.name}`,
description: 'My professional portfolio featuring past projects and contact info.',
}
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const [profile, header] = await Promise.all([fetchProfile(), fetchHeader()])
let profile: Profile | null = null
let header: Header | null = null
try {
;[profile, header] = await Promise.all([fetchProfile(), fetchHeader()])
} catch (error) {}
return (
<html lang="en" className={`${inter.className} dark`}>

View File

@@ -1,30 +1,98 @@
import React from 'react'
import { Metadata, ResolvingMetadata } from 'next'
import { notFound } from 'next/navigation'
import { Media } from '../payload/payload-types'
import { Media, Page, Profile } from '../payload/payload-types'
import { staticHome } from '../payload/seed/home-static'
import { ContentLayout } from './_components/content/contentLayout'
import { fetchPage, fetchProfile } from './_utils/api'
import { fetchPage, fetchPages, fetchProfile } from './_utils/api'
import { parsePreviewOptions } from './_utils/preview'
// Payload Cloud caches all files through Cloudflare, so we don't need Next.js to cache them as well
// This means that we can turn off Next.js data caching and instead rely solely on the Cloudflare CDN
// To do this, we include the `no-cache` header on the fetch requests used to get the data for this page
// But we also need to force Next.js to dynamically render this page on each request for preview mode to work
// See https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic
// If you are not using Payload Cloud then this line can be removed, see `../../../README.md#cache`
export const dynamic = 'force-dynamic'
interface LandingPageProps {
params: {
slug: string
}
searchParams: Record<string, string>
}
export default async function LandingPage({ params, searchParams }: LandingPageProps) {
const { slug } = params
const options = parsePreviewOptions(searchParams)
let page: Page | null = null
let profile: Profile | null = null
try {
;[page, profile] = await Promise.all([fetchPage('home', options), fetchProfile()])
} catch (error) {
// when deploying this template on Payload Cloud, this page needs to build before the APIs are live
// so swallow the error here and simply render the page with fallback data where necessary
// in production you may want to redirect to a 404 page or at least log the error somewhere
// console.error(error)
}
// if no `home` page exists, render a static one using dummy content
// you should delete this code once you have a home page in the CMS
// this is really only useful for those who are demoing this template
if (!page && !profile && slug === 'home') {
page = staticHome
}
if (!page) {
return notFound()
}
return <ContentLayout profile={profile} layout={page.layout} />
}
export async function generateStaticParams() {
try {
const pages = await fetchPages()
return pages.map(({ slug }) => ({ params: { slug } }))
} catch (error) {
return []
}
}
export async function generateMetadata(
{ searchParams }: LandingPageProps,
{ params, searchParams }: LandingPageProps,
parent?: ResolvingMetadata,
): Promise<Metadata> {
const defaultTitle = (await parent)?.title?.absolute
const { slug } = params
const options = parsePreviewOptions(searchParams)
const page = await fetchPage('home', options)
let page: Page | null = null
try {
page = await fetchPage('home', options)
} catch (error) {
// don't throw an error if the fetch fails
// this is so that we can render a static home page for the demo
// when deploying this template on Payload Cloud, this page needs to build before the APIs are live
// in production you may want to redirect to a 404 page or at least log the error somewhere
}
const defaultTitle = (await parent)?.title?.absolute
const title = page?.meta?.title || defaultTitle
const description = page?.meta?.description || 'A portfolio of work by a digital professional.'
const images = []
if (page?.meta?.image) {
images.push((page.meta.image as Media).url)
}
if (!page && slug === 'home') {
page = staticHome
}
return {
title,
description,
@@ -36,10 +104,3 @@ export async function generateMetadata(
},
}
}
export default async function LandingPage({ searchParams }: LandingPageProps) {
const options = parsePreviewOptions(searchParams)
const [page, profile] = await Promise.all([fetchPage('home', options), fetchProfile()])
return <ContentLayout profile={profile} layout={page.layout} />
}

View File

@@ -1,11 +1,15 @@
import { Metadata, ResolvingMetadata } from 'next'
import { notFound } from 'next/navigation'
import { Media } from '../../../payload/payload-types'
import { Media, Profile, Project } from '../../../payload/payload-types'
import { ProjectDetails } from '../../_components/content/projectDetails/projectDetails'
import { fetchProfile, fetchProject } from '../../_utils/api'
import { fetchProfile, fetchProject, fetchProjects } from '../../_utils/api'
import { parsePreviewOptions } from '../../_utils/preview'
// Force this page to be dynamic so that Next.js does not cache it
// See the note in '../../[slug]/page.tsx' about this
export const dynamic = 'force-dynamic'
interface ProjectPageProps {
params: {
slug: string
@@ -13,14 +17,48 @@ interface ProjectPageProps {
searchParams: Record<string, string>
}
export default async function ProjectPage({ params, searchParams }: ProjectPageProps) {
let project: Project | null = null
let profile: Profile | null = null
try {
;[project, profile] = await Promise.all([
fetchProject(params.slug, parsePreviewOptions(searchParams)),
fetchProfile(),
])
} catch (error) {
console.error(error) // eslint-disable-line no-console
}
if (!project) {
notFound()
}
return <ProjectDetails project={project} profile={profile} />
}
export async function generateStaticParams() {
try {
const pages = await fetchProjects()
return pages.map(({ slug }) => ({ params: { slug } }))
} catch (error) {
return []
}
}
export async function generateMetadata(
{ params, searchParams }: ProjectPageProps,
parent?: ResolvingMetadata,
): Promise<Metadata> {
const [project, previousTitle] = await Promise.all([
fetchProject(params.slug, parsePreviewOptions(searchParams)),
(await parent)?.title.absolute,
])
let project: Project | null = null
let previousTitle: string | null = null
try {
;[project, previousTitle] = await Promise.all([
fetchProject(params.slug, parsePreviewOptions(searchParams)),
(await parent)?.title.absolute,
])
} catch (error) {}
const images: string[] = []
if (project?.meta?.image) {
@@ -44,16 +82,3 @@ export async function generateMetadata(
},
}
}
export default async function ProjectPage({ params, searchParams }: ProjectPageProps) {
const [project, profile] = await Promise.all([
fetchProject(params.slug, parsePreviewOptions(searchParams)),
fetchProfile(),
])
if (!project) {
notFound()
}
return <ProjectDetails project={project} profile={profile} />
}

View File

@@ -0,0 +1,14 @@
import type { Page } from '../payload-types'
export const staticHome: Page = {
id: '',
title: 'Profile Landing Page',
slug: 'home',
createdAt: '',
updatedAt: '',
meta: {
title: 'Payload Developer Portfolio Template',
description: 'An open-source developer portfolio built with Payload and Next.js.',
},
layout: [],
}