444 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @type {import('next').NextConfig} */
const isProduction = process.env.NODE_ENV === 'production'
function trimToNull(value) {
const normalized = value?.trim()
return normalized ? normalized : null
}
function buildUrlOrigin(rawUrl) {
const normalized = trimToNull(rawUrl)
if (!normalized) {
return null
}
const withProtocol = /^https?:\/\//i.test(normalized)
? normalized
: `https://${normalized}`
try {
const parsed = new URL(withProtocol)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}
return parsed.origin
} catch {
return null
}
}
function collectStorageConnectSources() {
const origins = new Set()
for (const candidate of [
process.env.S3_PUBLIC_DOMAIN,
process.env.S3_PUBLIC_ENDPOINT,
process.env.S3_PUBLIC_URL,
process.env.S3_ENDPOINT,
process.env.QINIU_PUBLIC_DOMAIN,
process.env.QINIU_PUBLIC_ENDPOINT,
process.env.QINIU_PUBLIC_URL,
]) {
const origin = buildUrlOrigin(candidate)
if (origin) {
origins.add(origin)
}
}
return Array.from(origins)
}
const connectSources = [
"'self'",
'https://login.dingtalk.com',
'https://api.dingtalk.com',
'https://oapi.dingtalk.com',
...collectStorageConnectSources(),
'ws://127.0.0.1:8000',
'ws://127.0.0.1:18000',
'ws://localhost:8000',
'ws://localhost:18000',
'wss://localhost.lodop.net:8443',
...(!isProduction ? ['ws:', 'http://localhost:*', 'http://127.0.0.1:*'] : []),
]
const lodopScriptSources = [
'http://127.0.0.1:8000',
'http://127.0.0.1:18000',
'http://localhost:8000',
'http://localhost:18000',
'https://localhost.lodop.net:8443',
]
const contentSecurityPolicy = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
`frame-src 'self' ${lodopScriptSources.join(' ')}`,
"form-action 'self'",
"img-src 'self' data: blob: http: https:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
`script-src 'self' 'unsafe-inline' ${lodopScriptSources.join(' ')}${isProduction ? '' : " 'unsafe-eval'"}`,
`connect-src ${connectSources.join(' ')}`,
"worker-src 'self' blob:",
"media-src 'self' blob:",
...(isProduction ? ["upgrade-insecure-requests"] : []),
].join('; ')
const baseSecurityHeaders = [
{
key: 'X-Frame-Options',
value: 'DENY'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=()'
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin'
},
{
key: 'Cross-Origin-Resource-Policy',
value: 'same-origin'
},
{
key: 'Content-Security-Policy',
value: contentSecurityPolicy
},
...(isProduction
? [
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains'
},
]
: []),
]
function buildRemotePatternFromUrl(rawUrl) {
const normalized = trimToNull(rawUrl)
if (!normalized) {
return null
}
try {
const withProtocol = /^https?:\/\//i.test(normalized)
? normalized
: `https://${normalized}`
const parsed = new URL(withProtocol)
const pathname = parsed.pathname.replace(/\/+$/, '')
return {
protocol: parsed.protocol.replace(':', ''),
hostname: parsed.hostname,
...(parsed.port ? { port: parsed.port } : {}),
pathname: pathname ? `${pathname}/**` : '/**',
}
} catch {
return null
}
}
const remotePatterns = [
{
protocol: 'http',
hostname: 'localhost',
port: '9000',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'minio',
port: '9000',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'threejs.org',
pathname: '/**',
},
]
for (const candidate of [
process.env.S3_PUBLIC_DOMAIN,
process.env.S3_PUBLIC_ENDPOINT,
process.env.S3_PUBLIC_URL,
process.env.S3_ENDPOINT,
process.env.QINIU_PUBLIC_DOMAIN,
process.env.QINIU_PUBLIC_ENDPOINT,
process.env.QINIU_PUBLIC_URL,
]) {
const pattern = buildRemotePatternFromUrl(candidate)
if (!pattern) {
continue
}
const duplicated = remotePatterns.some(
(existing) =>
existing.protocol === pattern.protocol &&
existing.hostname === pattern.hostname &&
(existing.port || '') === (pattern.port || '') &&
existing.pathname === pattern.pathname,
)
if (!duplicated) {
remotePatterns.push(pattern)
}
}
function buildClientStorageEnv() {
return {
STORAGE_PROVIDER:
trimToNull(process.env.STORAGE_PROVIDER) ||
trimToNull(process.env.OBJECT_STORAGE_PROVIDER) ||
'',
OBJECT_STORAGE_PROVIDER:
trimToNull(process.env.OBJECT_STORAGE_PROVIDER) ||
trimToNull(process.env.STORAGE_PROVIDER) ||
'',
STORAGE_FILE_PATH:
trimToNull(process.env.STORAGE_FILE_PATH) ||
trimToNull(process.env.OBJECT_STORAGE_FILE_PATH) ||
'',
OBJECT_STORAGE_FILE_PATH:
trimToNull(process.env.OBJECT_STORAGE_FILE_PATH) ||
trimToNull(process.env.STORAGE_FILE_PATH) ||
'',
S3_FILE_PATH: trimToNull(process.env.S3_FILE_PATH) || '',
QINIU_FILE_PATH: trimToNull(process.env.QINIU_FILE_PATH) || '',
S3_PUBLIC_DOMAIN: trimToNull(process.env.S3_PUBLIC_DOMAIN) || '',
S3_PUBLIC_ENDPOINT: trimToNull(process.env.S3_PUBLIC_ENDPOINT) || '',
S3_PUBLIC_URL: trimToNull(process.env.S3_PUBLIC_URL) || '',
S3_ENDPOINT: trimToNull(process.env.S3_ENDPOINT) || '',
S3_BUCKET: trimToNull(process.env.S3_BUCKET) || '',
QINIU_PUBLIC_DOMAIN: trimToNull(process.env.QINIU_PUBLIC_DOMAIN) || '',
QINIU_PUBLIC_ENDPOINT: trimToNull(process.env.QINIU_PUBLIC_ENDPOINT) || '',
QINIU_PUBLIC_URL: trimToNull(process.env.QINIU_PUBLIC_URL) || '',
QINIU_USE_HTTPS: trimToNull(process.env.QINIU_USE_HTTPS) || '',
}
}
const clientStorageEnv = buildClientStorageEnv()
const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || '.next',
// 启用standalone输出,用于Docker部署
output: 'standalone',
// 启用构建时类型检查
typescript: {
ignoreBuildErrors: false,
},
// 服务器外部包配置(Next.js 16新配置)
serverExternalPackages: ['sharp', 'three', 'gsap', '@huggingface/transformers', 'onnxruntime-node', 'mssql'],
// Turbopack配置(Next.js 16默认启用)
turbopack: {},
// Keep single request bodies bounded. Large non-integrated files use the chunked upload route.
experimental: {
cpus: 2,
proxyClientMaxBodySize: 500 * 1024 * 1024,
},
// 本地开发允许 localhost 和 127.0.0.1 复用同一套 dev 资源,避免 UI 改动只在其中一个地址生效
allowedDevOrigins: ['localhost', '127.0.0.1', '192.168.42.134'],
// 图像优化配置
images: {
remotePatterns,
unoptimized: true, // 在容器环境中禁用优化以提高性能
formats: ['image/webp', 'image/avif']
},
// 静态资源配置
assetPrefix: process.env.NODE_ENV === 'production' ? undefined : undefined,
// 压缩配置
compress: true,
// 性能优化
poweredByHeader: false,
generateEtags: false,
// Webpack配置
webpack: (config, { dev, isServer }) => {
// 支持3D文件格式
config.module.rules.push({
test: /\.(glb|gltf|fbx|obj|dae)$/,
type: 'asset/resource',
generator: {
filename: 'static/models/[name].[hash][ext]'
}
})
// 支持音频文件(用于3D场景)
config.module.rules.push({
test: /\.(mp3|wav|ogg)$/,
type: 'asset/resource',
generator: {
filename: 'static/audio/[name].[hash][ext]'
}
})
// 支持着色器文件
config.module.rules.push({
test: /\.(glsl|vs|fs|vert|frag)$/,
type: 'asset/source'
})
// 优化Three.js打包
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
crypto: false
}
}
// 生产环境优化
if (!dev) {
// 启用代码分割
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
// Three.js单独打包
three: {
test: /[\\/]node_modules[\\/](three|@three)[\\/]/,
name: 'three',
chunks: 'all',
priority: 10
},
// GSAP单独打包
gsap: {
test: /[\\/]node_modules[\\/]gsap[\\/]/,
name: 'gsap',
chunks: 'all',
priority: 10
},
// 其他vendor库
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 5
}
}
}
}
return config
},
// 环境变量配置
env: {
CUSTOM_KEY: process.env.CUSTOM_KEY,
...clientStorageEnv,
},
// 重定向配置
async redirects() {
const redirects = [
...(!isProduction
? [
{
source: '/:path*',
has: [
{
type: 'host',
value: '127.0.0.1',
},
],
destination: 'http://localhost:3000/:path*',
permanent: false,
},
]
: []),
// 将根路径重定向到主页
{
source: '/home',
destination: '/',
permanent: true,
},
]
return redirects
},
// 头部配置(安全性和性能)
async headers() {
return [
{
source: '/(.*)',
headers: baseSecurityHeaders,
},
{
// Next 静态资源在生产环境可长期缓存;开发环境必须禁用缓存,否则浏览器会继续复用旧 bundle。
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: isProduction
? 'public, max-age=31536000, immutable'
: 'no-store, max-age=0, must-revalidate'
}
],
},
{
// Draco 解码器文件可长期缓存(静态文件)
source: '/draco/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable'
}
],
},
{
// 上传资源(模型/图片等)属于静态文件,但文件名未必带 hash,使用较保守的缓存策略
source: '/uploads/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=86400'
}
],
},
{
// 3D模型文件特殊缓存策略
source: '/models/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=86400, s-maxage=31536000'
}
],
},
{
// API 默认仅供同源应用访问,避免被任意外站脚本调用或探测。
source: '/api/:path*',
headers: [
// API 返回的是动态数据,必须禁用缓存,否则会出现“恢复/删除后列表不更新”等问题
{
key: 'Cache-Control',
value: 'no-store'
}
],
}
]
}
}
module.exports = nextConfig