feat(landing): migrate to vinext and fix prod regressions (#4882)

This commit is contained in:
DIYgod 2026-02-26 12:54:20 +08:00 committed by GitHub
parent 5e26d1d33d
commit 21fb6dfac0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 576 additions and 2594 deletions

View File

@ -1,17 +1,14 @@
import NextBundleAnalyzer from '@next/bundle-analyzer'
import { codeInspectorPlugin } from 'code-inspector-plugin'
import { config } from 'dotenv'
import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'
process.title = 'Folo Landing (NextJS)'
process.title = 'Folo Landing (vinext)'
const env = config().parsed || {}
const isProd = process.env.NODE_ENV === 'production'
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
let nextConfig: NextConfig = {
const nextConfig = {
reactStrictMode: false,
productionBrowserSourceMaps: true,
output: 'standalone',
@ -46,18 +43,6 @@ let nextConfig: NextConfig = {
],
}
},
turbopack: {
rules: codeInspectorPlugin({
bundler: 'turbopack',
hotKeys: ['altKey'],
}),
},
}
if (process.env.ANALYZE === 'true') {
nextConfig = NextBundleAnalyzer({
enabled: true,
})(nextConfig)
}
export default withNextIntl(nextConfig)

View File

@ -1,3 +0,0 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare'
export default defineCloudflareConfig()

View File

@ -4,11 +4,12 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build": "cross-env NODE_ENV=production next build",
"cf:build": "pnpm exec opennextjs-cloudflare build",
"cf:deploy": "pnpm exec wrangler deploy",
"cf:deploy:dev": "pnpm exec wrangler deploy --env dev",
"dev": "cross-env NODE_ENV=development next dev -p 4399",
"build": "cross-env NODE_ENV=production vinext build",
"cf:build": "cross-env NODE_ENV=production vinext build",
"cf:deploy": "pnpm exec vinext deploy",
"cf:deploy:dev": "pnpm exec vinext deploy --preview",
"dev": "cross-env NODE_ENV=development vinext dev -p 4399",
"start": "cross-env NODE_ENV=production vinext start -p 4399",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@ -32,7 +33,6 @@
"jotai": "2.15.0",
"js-cookie": "3.0.5",
"motion": "12.23.24",
"next": "16.0.11",
"next-intl": "4.4.0",
"next-themes": "0.4.6",
"ogl": "1.0.11",
@ -54,23 +54,24 @@
"tailwind-merge": "3.3.1",
"unified": "11.0.5",
"usehooks-ts": "3.1.1",
"vaul": "1.1.2"
"vaul": "1.1.2",
"vinext": "0.0.9"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.25.5",
"@egoist/tailwindcss-icons": "1.9.0",
"@iconify-json/lucide": "1.2.71",
"@iconify-json/mingcute": "1.2.5",
"@iconify-json/simple-icons": "1.2.56",
"@iconify/tailwind": "1.2.0",
"@innei/prettier": "1.0.0",
"@next/bundle-analyzer": "16.0.11",
"@opennextjs/cloudflare": "1.16.5",
"@tailwindcss/postcss": "4.1.16",
"@tailwindcss/typography": "0.5.19",
"@tanstack/react-query-devtools": "5.90.2",
"@types/node": "24.9.1",
"@types/react": "19.1.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-rsc": "0.5.21",
"autoprefixer": "10.4.21",
"babel-plugin-react-compiler": "1.0.0",
"code-inspector-plugin": "1.2.10",
@ -87,6 +88,7 @@
"tailwindcss-animate": "1.0.7",
"tailwindcss-safe-area": "1.1.0",
"typescript": "catalog:",
"wrangler": "4.67.0"
"vite": "7.3.1",
"wrangler": "4.68.1"
}
}

View File

@ -1,23 +0,0 @@
'use server'
export async function getGithubStar() {
return await fetch(`https://api.github.com/repos/RSSNext/Folo`, {
headers: {
Authorization: process.env.GITHUB_TOKEN
? `Bearer ${process.env.GITHUB_TOKEN}`
: '',
},
})
.then((res) => res.json())
.then((data) => {
if (data.message) {
throw new Error(data.message)
}
return data.stargazers_count as number
})
.catch((e) => {
console.error(e)
return -1
})
}

View File

@ -4,14 +4,24 @@ import { getTranslations } from 'next-intl/server'
import { DownloadHero } from '~/components/widgets/download/DownloadHero'
import { PlatformDownloads } from '~/components/widgets/download/PlatformDownloads'
import { defaultLocale, locales } from '~/i18n/routing'
import { detectPlatform } from '~/lib/platform'
type LocaleParams = { locale?: string }
const localeSet = new Set(locales)
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
params: Promise<LocaleParams> | LocaleParams | undefined
}): Promise<Metadata> {
const { locale } = await params
const localeFromParams = params ? (await params).locale : undefined
const locale =
localeFromParams &&
localeSet.has(localeFromParams as (typeof locales)[number])
? localeFromParams
: defaultLocale
const t = await getTranslations({ locale, namespace: 'download.metadata' })
return {

View File

@ -8,7 +8,7 @@ import { Root } from '~/components/layout/root/Root'
import { LightRays } from '~/components/ui/light-rays'
import { LandingHeader } from '~/components/widgets/landing/Header'
import { siteInfo } from '~/constants/site'
import { locales } from '~/i18n/routing'
import { defaultLocale, locales } from '~/i18n/routing'
import { sansFont } from '~/lib/fonts'
import { Providers } from '../../providers/root'
@ -18,14 +18,30 @@ import { InitInClient } from '../InitInClient'
init()
type LocaleParams = { locale?: string }
type MaybeAsyncLocaleParams = LocaleParams | Promise<LocaleParams> | undefined
const localeSet = new Set(locales)
const resolveLocale = async (params: MaybeAsyncLocaleParams) => {
const locale = params ? (await params).locale : undefined
if (locale && localeSet.has(locale as (typeof locales)[number])) {
return locale
}
return defaultLocale
}
export function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
export async function generateMetadata(
params: Promise<{ locale: string }>,
): Promise<Metadata> {
const { locale } = await params
export async function generateMetadata(props: {
params: MaybeAsyncLocaleParams
}): Promise<Metadata> {
const locale = await resolveLocale(props.params)
const t = await getTranslations({ locale, namespace: 'metadata' })
const title = t('title', { defaultValue: siteInfo.title })
@ -107,10 +123,10 @@ export default async function LocaleLayout({
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
params: MaybeAsyncLocaleParams
}) {
const { locale } = await params
const messages = await getMessages()
const locale = await resolveLocale(params)
const messages = await getMessages({ locale })
return (
<>

View File

@ -1,10 +1,29 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { getGithubStar } from '~/actions/github-star'
type GithubRepoStatsResponse = {
repo?: {
stars?: number
}
}
export const useGithubStar = () =>
useQuery({
queryKey: ['github-star'],
queryFn: () => getGithubStar(),
queryFn: async () => {
try {
const response = await fetch('https://ungh.cc/repos/RSSNext/Folo')
if (!response.ok) {
throw new Error(`Failed to fetch repo stats: ${response.status}`)
}
const data = (await response.json()) as GithubRepoStatsResponse
return typeof data.repo?.stars === 'number' ? data.repo.stars : -1
} catch (error) {
console.error(error)
return -1
}
},
staleTime: 1000 * 60 * 10,
})

View File

@ -1,7 +1,25 @@
import { getRequestConfig } from 'next-intl/server'
export default getRequestConfig(async ({ locale, requestLocale }) => {
const localeValue = locale || (await requestLocale) || 'en'
type RequestConfigParams = {
locale?: string
requestLocale?: Promise<string | undefined> | string | undefined
}
const SUPPORTED_LOCALES = ['en', 'zh', 'jp'] as const
const DEFAULT_LOCALE = 'en'
const localeSet = new Set<string>(SUPPORTED_LOCALES)
export default getRequestConfig(async (params?: RequestConfigParams) => {
const localeFromParam = params?.locale
const requestLocaleValue = params?.requestLocale
const localeFromRequest = requestLocaleValue
? await requestLocaleValue
: undefined
const requestedLocale = localeFromParam || localeFromRequest
const localeValue =
requestedLocale && localeSet.has(requestedLocale)
? requestedLocale
: DEFAULT_LOCALE
return {
locale: localeValue,

View File

@ -1,9 +0,0 @@
import createMiddleware from 'next-intl/middleware'
import { routing } from '~/i18n/routing'
export default createMiddleware(routing)
export const config = {
matcher: ['/((?!_next|api|apple-app-site-association|.*\\..*).*)'],
}

58
apps/landing/src/proxy.ts Normal file
View File

@ -0,0 +1,58 @@
import { NextResponse } from 'next/server'
import { defaultLocale, locales } from '~/i18n/routing'
const localeSet = new Set(locales)
const rscSuffix = '.rsc'
const bypassPrefixes = ['/_next', '/_vinext', '/api']
const bypassExactPaths = new Set([
'/apple-app-site-association',
'/.well-known/apple-app-site-association',
])
const getLogicalPathname = (pathname: string) => {
if (!pathname.endsWith(rscSuffix)) {
return pathname
}
const logicalPathname = pathname.slice(0, -rscSuffix.length)
return logicalPathname === '' ? '/' : logicalPathname
}
export function proxy(request: Request) {
const url = new URL(request.url)
const rawPathname = url.pathname
const pathname = getLogicalPathname(rawPathname)
if (bypassPrefixes.some((prefix) => pathname.startsWith(prefix))) {
return NextResponse.next()
}
if (bypassExactPaths.has(pathname)) {
return NextResponse.next()
}
const hasFileExtension = /\.[^/]+$/.test(rawPathname)
if (hasFileExtension && !rawPathname.endsWith(rscSuffix)) {
return NextResponse.next()
}
const firstSegment = pathname.split('/').find(Boolean)
if (firstSegment && localeSet.has(firstSegment as (typeof locales)[number])) {
return NextResponse.next()
}
const rewritePath =
pathname === '/' ? `/${defaultLocale}` : `/${defaultLocale}${pathname}`
const rewriteUrl = new URL(rewritePath, request.url)
rewriteUrl.search = url.search
return NextResponse.rewrite(rewriteUrl)
}
export const middleware = proxy
export default proxy
export const config = {
matcher: ['/:path*'],
}

View File

@ -1,13 +1,13 @@
@import 'tailwindcss';
@import 'tailwindcss-safe-area';
@import './pastel-theme-oklch.css';
@plugin "@tailwindcss/typography";
@plugin '@egoist/tailwindcss-icons';
@plugin "tailwind-scrollbar";
@plugin 'tailwindcss-animate';
@import './pastel-theme-oklch.css';
@source "./src/**/*.{js,jsx,ts,tsx}";
@source "../**/*.{js,jsx,ts,tsx}";
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
@theme {

View File

@ -0,0 +1,24 @@
import { fileURLToPath } from 'node:url'
import { cloudflare } from '@cloudflare/vite-plugin'
import vinext from 'vinext'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
vinext(),
cloudflare({
viteEnvironment: {
name: 'rsc',
childEnvironments: ['ssr'],
},
}),
],
resolve: {
alias: {
'next-intl/config': fileURLToPath(
new URL('src/i18n/request.ts', import.meta.url),
),
},
},
})

View File

@ -0,0 +1,23 @@
import handler from 'vinext/server/app-router-entry'
import { handleImageOptimization } from 'vinext/server/image-optimization'
export default {
async fetch(request, env) {
const url = new URL(request.url)
if (url.pathname === '/_vinext/image') {
return handleImageOptimization(request, {
fetchAsset: (path) =>
env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body)
.transform(width > 0 ? { width } : {})
.output({ format, quality })
return result.response()
},
})
}
return handler.fetch(request)
},
}

View File

@ -1,13 +1,17 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "landing-next",
"main": ".open-next/worker.js",
"name": "landing-vinext",
"main": "./worker/index.js",
"compatibility_date": "2026-02-01",
"compatibility_flags": ["nodejs_compat"],
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"assets": {
"directory": ".open-next/assets",
"directory": "./dist/client",
"binding": "ASSETS",
"not_found_handling": "none",
},
"images": {
"binding": "IMAGES",
},
"routes": [
{

File diff suppressed because it is too large Load Diff