mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
72
apps/ssr/src/app/[...all]/dev.ts
Normal file
72
apps/ssr/src/app/[...all]/dev.ts
Normal 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()
|
||||
}
|
||||
9
apps/ssr/src/app/[...all]/route.ts
Normal file
9
apps/ssr/src/app/[...all]/route.ts
Normal 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'
|
||||
1
apps/ssr/src/app/[photoId]/dev.ts
Normal file
1
apps/ssr/src/app/[photoId]/dev.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { handler } from '../[...all]/dev'
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function NotFound() {
|
||||
return <div>Not Found</div>
|
||||
}
|
||||
115
apps/ssr/src/app/[photoId]/prod.ts
Normal file
115
apps/ssr/src/app/[photoId]/prod.ts
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user