feat: enhance SSR routing and development experience

- Updated the SSR routing to conditionally load development and production handlers based on the NODE_ENV variable.
- Introduced a new dev.ts file to handle requests in development mode, including asset proxying and HTML response generation.
- Removed the not-found.tsx file and streamlined the photoId route logic for better maintainability.
- Added support for Open Graph and Twitter meta tags in the production handler for improved SEO.
- Updated README with new S3 endpoint configuration.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-23 11:31:28 +08:00
parent 66860e3eb6
commit c58835e35b
11 changed files with 218 additions and 124 deletions

View File

@@ -174,7 +174,8 @@ Create `builder.config.json` file for advanced configuration:
"bucket": "my-photos",
"region": "us-east-1",
"prefix": "photos/",
"customDomain": "https://cdn.example.com"
"customDomain": "https://cdn.example.com",
"endpoint": "https://s3.amazonaws.com"
},
"options": {
"defaultConcurrency": 8,

View File

@@ -12,7 +12,7 @@
"build:jpg": "node scripts/webp-to-jpg.js",
"build:next": "next build",
"deploy": "npm run pages:build && wrangler pages deploy",
"dev": "next dev",
"dev": "next dev --turbo --port 1975",
"pages:build": "pnpm dlx @cloudflare/next-on-pages",
"preview": "npm run pages:build && wrangler pages dev",
"start": "next start"

View File

@@ -0,0 +1,72 @@
import { DOMParser } from 'linkedom'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
const host = 'http://localhost:1924'
export const handler = async (req: NextRequest) => {
if (process.env.NODE_ENV !== 'development') {
return new NextResponse(null, { status: 404 })
}
if (req.nextUrl.pathname.startsWith('/thumbnails')) {
return proxyAssets(req)
}
return proxyIndexHtml()
}
async function proxyAssets(req: NextRequest) {
const url = new URL(req.url)
const { pathname } = url
const response = await fetch(host + pathname)
return new NextResponse(response.body, {
headers: response.headers,
})
}
async function proxyIndexHtml() {
const htmlText = await fetch(host).then((res) => res.text())
const parser = new DOMParser()
const document = parser.parseFromString(
htmlText,
'text/html',
) as unknown as HTMLDocument
const scripts = document.querySelectorAll(
'script',
) as NodeListOf<HTMLScriptElement>
scripts.forEach((script) => {
if (script.src.startsWith('/')) {
script.src = replaceUrl(script.src, host)
}
})
const links = document.head.querySelectorAll('link')
links.forEach((link) => {
if (link.href.startsWith('/')) {
link.href = replaceUrl(link.href, host)
}
})
const injectScripts = document.querySelectorAll('script[type="module"]')
injectScripts.forEach((script) => {
script.innerHTML = script.innerHTML
.replace(
'/@vite-plugin-checker-runtime',
`${host}/@vite-plugin-checker-runtime`,
)
.replace('/@react-refresh', `${host}/@react-refresh`)
})
return new NextResponse(document.documentElement.outerHTML, {
headers: { 'Content-Type': 'text/html' },
})
}
const replaceUrl = (url: string, host: string) => {
return new URL(
url.startsWith('http') ? new URL(url).pathname : url,
new URL(host),
).toString()
}

View File

@@ -0,0 +1,9 @@
export {
handler as DELETE,
handler as GET,
handler as HEAD,
handler as OPTIONS,
handler as PATCH,
handler as POST,
handler as PUT,
} from './dev'

View File

@@ -0,0 +1 @@
export { handler } from '../[...all]/dev'

View File

@@ -1,3 +0,0 @@
export default function NotFound() {
return <div>Not Found</div>
}

View File

@@ -0,0 +1,115 @@
import { photoLoader } from '@afilmory/data'
import type { PhotoManifest } from '@afilmory/data/types'
import { DOMParser } from 'linkedom'
import type { NextRequest } from 'next/server'
import indexHtml from '../../index.html'
type HtmlElement = ReturnType<typeof DOMParser.prototype.parseFromString>
type OnlyHTMLDocument = HtmlElement extends infer T
? T extends { [key: string]: any; head: any }
? T
: never
: never
export const handler = async (
request: NextRequest,
{ params }: { params: Promise<{ photoId: string }> },
) => {
const { photoId } = await params
const photo = photoLoader.getPhoto(photoId)
if (!photo) {
return new Response(indexHtml, {
headers: { 'Content-Type': 'text/html' },
status: 404,
})
}
try {
const document = new DOMParser().parseFromString(indexHtml, 'text/html')
// Remove all twitter meta tags and open graph meta tags
document.head.childNodes.forEach((node) => {
if (node.nodeName === 'META') {
const $meta = node as HTMLMetaElement
if ($meta.getAttribute('name')?.startsWith('twitter:')) {
$meta.remove()
}
if ($meta.getAttribute('property')?.startsWith('og:')) {
$meta.remove()
}
}
})
// Insert meta open graph tags and twitter meta tags
createAndInsertOpenGraphMeta(document, photo, request)
return new Response(document.documentElement.outerHTML, {
headers: {
'Content-Type': 'text/html',
'X-SSR': '1',
},
})
} catch (error) {
console.error('Error generating SSR page:', error)
console.info('Falling back to static index.html')
console.info(error.message)
return new Response(indexHtml, {
headers: { 'Content-Type': 'text/html' },
status: 500,
})
}
}
const createAndInsertOpenGraphMeta = (
document: OnlyHTMLDocument,
photo: PhotoManifest,
request: NextRequest,
) => {
// Open Graph meta tags
// X forward host
const xForwardedHeaders = {
'x-forwarded-host': request.headers.get('x-forwarded-host'),
'x-forwarded-proto': request.headers.get('x-forwarded-proto'),
'x-forwarded-for': request.headers.get('x-forwarded-for'),
}
let realOrigin = request.nextUrl.origin
if (xForwardedHeaders['x-forwarded-host']) {
realOrigin = `${xForwardedHeaders['x-forwarded-proto'] || 'https'}://${xForwardedHeaders['x-forwarded-host']}`
}
const ogTags = {
'og:type': 'website',
'og:title': photo.id,
'og:description': photo.description || '',
'og:image': `${realOrigin}/og/${photo.id}`,
'og:url': `${realOrigin}/${photo.id}`,
}
for (const [property, content] of Object.entries(ogTags)) {
const ogMeta = document.createElement('meta', {})
ogMeta.setAttribute('property', property)
ogMeta.setAttribute('content', content)
document.head.append(ogMeta as unknown as Node)
}
// Twitter Card meta tags
const twitterTags = {
'twitter:card': 'summary_large_image',
'twitter:title': photo.id,
'twitter:description': photo.description || '',
'twitter:image': `${realOrigin}/og/${photo.id}`,
}
for (const [name, content] of Object.entries(twitterTags)) {
const twitterMeta = document.createElement('meta', {})
twitterMeta.setAttribute('name', name)
twitterMeta.setAttribute('content', content)
document.head.append(twitterMeta as unknown as Node)
}
return document
}

View File

@@ -1,115 +1,7 @@
import { photoLoader } from '@afilmory/data'
import type { PhotoManifest } from '@afilmory/data/types'
import { DOMParser } from 'linkedom'
import type { NextRequest } from 'next/server'
import indexHtml from '../../index.html'
type HtmlElement = ReturnType<typeof DOMParser.prototype.parseFromString>
type OnlyHTMLDocument = HtmlElement extends infer T
? T extends { [key: string]: any; head: any }
? T
: never
: never
export const runtime = 'edge'
export const GET = async (
request: NextRequest,
{ params }: { params: Promise<{ photoId: string }> },
) => {
const { photoId } = await params
const photo = photoLoader.getPhoto(photoId)
if (!photo) {
return new Response(indexHtml, {
headers: { 'Content-Type': 'text/html' },
status: 404,
})
}
try {
const document = new DOMParser().parseFromString(indexHtml, 'text/html')
// Remove all twitter meta tags and open graph meta tags
document.head.childNodes.forEach((node) => {
if (node.nodeName === 'META') {
const $meta = node as HTMLMetaElement
if ($meta.getAttribute('name')?.startsWith('twitter:')) {
$meta.remove()
}
if ($meta.getAttribute('property')?.startsWith('og:')) {
$meta.remove()
}
}
})
// Insert meta open graph tags and twitter meta tags
createAndInsertOpenGraphMeta(document, photo, request)
return new Response(document.documentElement.outerHTML, {
headers: {
'Content-Type': 'text/html',
'X-SSR': '1',
},
})
} catch (error) {
console.error('Error generating SSR page:', error)
console.info('Falling back to static index.html')
console.info(error.message)
return new Response(indexHtml, {
headers: { 'Content-Type': 'text/html' },
status: 500,
})
}
}
const createAndInsertOpenGraphMeta = (
document: OnlyHTMLDocument,
photo: PhotoManifest,
request: NextRequest,
) => {
// Open Graph meta tags
// X forward host
const xForwardedHeaders = {
'x-forwarded-host': request.headers.get('x-forwarded-host'),
'x-forwarded-proto': request.headers.get('x-forwarded-proto'),
'x-forwarded-for': request.headers.get('x-forwarded-for'),
}
let realOrigin = request.nextUrl.origin
if (xForwardedHeaders['x-forwarded-host']) {
realOrigin = `${xForwardedHeaders['x-forwarded-proto'] || 'https'}://${xForwardedHeaders['x-forwarded-host']}`
}
const ogTags = {
'og:type': 'website',
'og:title': photo.id,
'og:description': photo.description || '',
'og:image': `${realOrigin}/og/${photo.id}`,
'og:url': `${realOrigin}/${photo.id}`,
}
for (const [property, content] of Object.entries(ogTags)) {
const ogMeta = document.createElement('meta', {})
ogMeta.setAttribute('property', property)
ogMeta.setAttribute('content', content)
document.head.append(ogMeta as unknown as Node)
}
// Twitter Card meta tags
const twitterTags = {
'twitter:card': 'summary_large_image',
'twitter:title': photo.id,
'twitter:description': photo.description || '',
'twitter:image': `${realOrigin}/og/${photo.id}`,
}
for (const [name, content] of Object.entries(twitterTags)) {
const twitterMeta = document.createElement('meta', {})
twitterMeta.setAttribute('name', name)
twitterMeta.setAttribute('content', content)
document.head.append(twitterMeta as unknown as Node)
}
return document
}
export const GET =
process.env.NODE_ENV === 'development'
? // @ts-expect-error
(...rest) => import('./dev').then((m) => m.handler(...rest))
: // @ts-expect-error
(...rest) => import('./prod').then((m) => m.handler(...rest))

View File

@@ -1,7 +1,13 @@
import type { NextRequest } from 'next/server'
import indexHtml from '../index.html'
export const runtime = 'edge'
export const GET = async () => {
export const GET = async (req: NextRequest) => {
if (process.env.NODE_ENV === 'development') {
return import('./[...all]/dev').then((m) => m.handler(req))
}
return new Response(indexHtml, {
headers: {
'Content-Type': 'text/html',

View File

@@ -1,5 +1,5 @@
import { Provider } from 'jotai'
import { LazyMotion, MotionConfig } from 'motion/react'
import { domMax, LazyMotion, MotionConfig } from 'motion/react'
import type { FC, PropsWithChildren } from 'react'
import { Toaster } from '~/components/ui/sonner'
@@ -12,10 +12,8 @@ import { I18nProvider } from './i18n-provider'
import { SettingSync } from './setting-sync'
import { StableRouterProvider } from './stable-router-provider'
const loadFeatures = () =>
import('../framer-lazy-feature').then((res) => res.default)
export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<LazyMotion features={loadFeatures} strict key="framer">
<LazyMotion features={domMax} strict key="framer">
<MotionConfig transition={Spring.presets.smooth}>
<Provider store={jotaiStore}>
<EventProvider />

View File

@@ -82,6 +82,9 @@ export default defineConfig({
}),
process.env.analyzer && analyzer(),
],
server: {
port: 1924, // 1924 年首款 35mm 相机问世
},
define: {
APP_DEV_CWD: JSON.stringify(process.cwd()),
APP_NAME: JSON.stringify(PKG.name),