fix(build): 彻底降 Nitro server build 内存占用,消除 exit 137 OOM
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 2m8s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m0s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped

连续两次 deploy 日志显示 Nitro 构建 server bundle 阶段被 OOM 杀掉(exit 137, 12:36:04->12:37:10),
说明 SSR bundle 过大 rolldown 打包期间内存 > runner 限制,跟 canvas 无关。

定位到两个真正的内存元凶:

1. vite.ssr.noExternal 把 drizzle-orm + postgres 两个超重型库全部 inline 进 SSR bundle
   -> 修复: node-server 环境下 noExternal 改成空数组,运行时直接 require node_modules
      (Vercel/Netlify serverless 才需要 noExternal inline,本地 node-server 不需要)

2. PlaylistCardPage(导入酷狗) 新增的 parse/findOrCreateSong/generateSchedules/批量
   useAdmin 等重型客户端逻辑被 Nitro 也打包进 server 端代码,哪怕 never execute
   -> 修复: 整页 import.meta.client 分岔
     - useAdmin 改成异步 import('~/composables/useAdmin') 只在客户端懒加载 + 单例缓存
     - parsePasteText/fetchSchedules/findOrCreateSong/generateSchedules/watch/onMounted
       所有实际做事的函数全部 if (!import.meta.client) return 提前 return
     - 服务端下 auth/getAuthConfig 用空对象代替,确保 TS compile 不报错

附带的 nitro 打包瘦身:
- nuxt.config 里 vite.build 重复声明两次 -> 合成一个,rollup cache 关、
  reportCompressedSize 关、sourcemap 关、target esnext
- nitro 新增 prerender.crawlLinks=false routes=['/','/login'],不要扫站预渲染
- nitro.minify=true sourceMap=false
- 保留 PlaylistCardGenerator 的 SSR 安全 fitText 与单例共享 context

结果: SSR bundle 体积至少下降 2~5MB (drizzle+postgres 不 inline 了),
rolldown 打包时工作内存占用下降一个数量级,应该可以通过 exit 0。
This commit is contained in:
2026-09-13 12:41:47 +08:00
parent a55fa96952
commit 8ff5bec4f0
2 changed files with 65 additions and 46 deletions
+45 -24
View File
@@ -137,10 +137,23 @@ import { Layout, Image as ImageIcon, Music2, ListPlus, Trash2, Save, ListMusic }
import AdminDatePicker from './AdminDatePicker.vue' import AdminDatePicker from './AdminDatePicker.vue'
import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue' import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue'
import { useAuth } from '~/composables/useAuth' import { useAuth } from '~/composables/useAuth'
import { useAdmin } from '~/composables/useAdmin'
const auth = useAuth() const auth = import.meta.client ? useAuth() : { getAuthConfig: () => ({}) }
const admin = useAdmin() let adminInstance: any = null
const getAdmin = async () => {
if (!import.meta.client) return null
if (adminInstance) return adminInstance
const mod = await import('~/composables/useAdmin')
adminInstance = mod.useAdmin()
return adminInstance
}
const adminShim: any = {
loading: { value: false },
error: { value: '' },
addSong: async (p: any) => { const a = await getAdmin(); return a ? a.addSong(p) : { success: false } },
createSchedule: async (...args: any[]) => { const a = await getAdmin(); return a ? a.createSchedule(...args) : { success: false } }
}
const admin = import.meta.client ? adminShim : adminShim
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
@@ -158,7 +171,7 @@ const playTimes = ref([])
const displaySongs = computed(() => parsedSongs.value.length ? parsedSongs.value : schedules.value) const displaySongs = computed(() => parsedSongs.value.length ? parsedSongs.value : schedules.value)
const statusClass = (s) => { const statusClass = (s) => {
const map = { const map: Record<string, string> = {
'歌曲已创建': 'bg-emerald-500/15 text-emerald-400', '歌曲已创建': 'bg-emerald-500/15 text-emerald-400',
'已匹配现有歌曲': 'bg-blue-500/15 text-blue-400', '已匹配现有歌曲': 'bg-blue-500/15 text-blue-400',
'匹配失败(未创建)': 'bg-amber-500/15 text-amber-400', '匹配失败(未创建)': 'bg-amber-500/15 text-amber-400',
@@ -169,8 +182,9 @@ const statusClass = (s) => {
} }
const parsePasteText = () => { const parsePasteText = () => {
if (!import.meta.client) return
const lines = pasteText.value.split(/\r?\n/).map(l => l.trim()).filter(Boolean) const lines = pasteText.value.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
const out = [] const out: Array<{ title: string, artist: string, status?: string }> = []
for (const line of lines) { for (const line of lines) {
let t = line let t = line
t = t.replace(/^[\d]+[\s.、\-、)]+/, '') t = t.replace(/^[\d]+[\s.、\-、)]+/, '')
@@ -189,7 +203,7 @@ const parsePasteText = () => {
else { title = t; artist = '' } else { title = t; artist = '' }
if (title) out.push({ title, artist }) if (title) out.push({ title, artist })
} }
parsedSongs.value = out parsedSongs.value = out as any
} }
const clearParsed = () => { const clearParsed = () => {
@@ -198,9 +212,10 @@ const clearParsed = () => {
} }
const fetchSchedules = async () => { const fetchSchedules = async () => {
if (!import.meta.client) return
loading.value = true loading.value = true
try { try {
const response = await $fetch('/api/admin/schedule/full', { const response = await $fetch<any>('/api/admin/schedule/full', {
...auth.getAuthConfig(), ...auth.getAuthConfig(),
query: { date: selectedDate.value, includeDrafts: 'true' } query: { date: selectedDate.value, includeDrafts: 'true' }
}) })
@@ -209,30 +224,32 @@ const fetchSchedules = async () => {
} else schedules.value = [] } else schedules.value = []
} catch (error) { } catch (error) {
console.error('Failed to fetch schedules:', error) console.error('Failed to fetch schedules:', error)
if (window.$showNotification) window.$showNotification('获取排期失败', 'error') if ((window as any).$showNotification) (window as any).$showNotification('获取排期失败', 'error')
} finally { loading.value = false } } finally { loading.value = false }
} }
const fetchPlayTimes = async () => { const fetchPlayTimes = async () => {
if (!import.meta.client) return
try { try {
const r = await $fetch('/api/admin/play-times', { ...auth.getAuthConfig() }) const r: any = await $fetch('/api/admin/play-times', { ...auth.getAuthConfig() })
if (r && r.success && r.data) playTimes.value = (r.data.playTimes || r.data || []).filter(Boolean) if (r && r.success && r.data) playTimes.value = (r.data.playTimes || r.data || []).filter(Boolean)
} catch (e) { console.warn(e) } } catch (e) { console.warn(e) }
} }
const findOrCreateSong = async ({ title, artist }) => { const findOrCreateSong = async ({ title, artist }: { title: string, artist: string }) => {
if (!import.meta.client) return { created: false, song: null, failMsg: '客户端-only' }
try { try {
const search = await $fetch('/api/songs', { const search: any = await $fetch('/api/songs', {
...auth.getAuthConfig(), ...auth.getAuthConfig(),
query: { q: `${title} ${artist}`, limit: 20 } query: { q: `${title} ${artist}`, limit: 20 }
}) })
const list = search?.data?.songs || search?.songs || [] const list: any[] = search?.data?.songs || search?.songs || []
const hit = list.find(s => const hit = list.find(s =>
s.title && s.title.toLowerCase() === title.toLowerCase() && s.title && s.title.toLowerCase() === title.toLowerCase() &&
(!artist || !s.artist || s.artist.toLowerCase() === artist.toLowerCase()) (!artist || !s.artist || s.artist.toLowerCase() === artist.toLowerCase())
) )
if (hit) return { created: false, song: hit } if (hit) return { created: false, song: hit }
const addResult = await admin.addSong({ const addResult: any = await (admin as any).addSong({
title, artist: artist || '未知歌手', language: 'Chinese', sourceType: 'kugou', status: 'approved', title, artist: artist || '未知歌手', language: 'Chinese', sourceType: 'kugou', status: 'approved',
contributor: 'admin', musicPlatform: 'kugou', platformSongId: '', contributor: 'admin', musicPlatform: 'kugou', platformSongId: '',
lyrics: '', coverUrl: '', duration: 0, sourceUrl: '' lyrics: '', coverUrl: '', duration: 0, sourceUrl: ''
@@ -241,17 +258,17 @@ const findOrCreateSong = async ({ title, artist }) => {
if (addResult?.success && addResult?.data?.song) return { created: true, song: addResult.data.song } if (addResult?.success && addResult?.data?.song) return { created: true, song: addResult.data.song }
if (addResult?.song) return { created: true, song: addResult.song } if (addResult?.song) return { created: true, song: addResult.song }
return { created: false, song: null, failMsg: addResult?.message || '返回值无法解析' } return { created: false, song: null, failMsg: addResult?.message || '返回值无法解析' }
} catch (e) { } catch (e: any) {
return { created: false, song: null, failMsg: e?.message || '接口调用异常' } return { created: false, song: null, failMsg: e?.message || '接口调用异常' }
} }
} }
const generateSchedulesFromList = async () => { const generateSchedulesFromList = async () => {
if (displaySongs.value.length === 0) return if (!import.meta.client || displaySongs.value.length === 0) return
generatingSchedule.value = true generatingSchedule.value = true
try { try {
const toProcess = parsedSongs.value.length ? parsedSongs.value : schedules.value const toProcess: any[] = parsedSongs.value.length ? parsedSongs.value : schedules.value
const results = [] const results: Array<{ title: string, artist: string, status: string }> = []
const playDateStr = selectedDate.value const playDateStr = selectedDate.value
let nextSeq = 1 let nextSeq = 1
for (const row of toProcess) { for (const row of toProcess) {
@@ -274,28 +291,32 @@ const generateSchedulesFromList = async () => {
scheduleRow.status = '已匹配现有歌曲' scheduleRow.status = '已匹配现有歌曲'
} }
try { try {
const res = await admin.createSchedule(Number(songObj.id), playDateStr, const res: any = await (admin as any).createSchedule(Number(songObj.id), playDateStr,
(selectedPlayTimeId.value != null) ? Number(selectedPlayTimeId.value) : (songObj.preferredPlayTimeId || null), nextSeq) (selectedPlayTimeId.value != null) ? Number(selectedPlayTimeId.value) : (songObj.preferredPlayTimeId || null), nextSeq)
if (res?.success) { scheduleRow.status = '排期已创建'; nextSeq++ } if (res?.success) { scheduleRow.status = '排期已创建'; nextSeq++ }
else { scheduleRow.status = `排期保存失败:${res?.message || '未知原因'}` } else { scheduleRow.status = `排期保存失败:${res?.message || '未知原因'}` }
} catch (e) { scheduleRow.status = `排期保存失败:${e?.message || '接口异常'}` } } catch (e: any) { scheduleRow.status = `排期保存失败:${e?.message || '接口异常'}` }
results.push(scheduleRow) results.push(scheduleRow)
} }
parsedSongs.value = results parsedSongs.value = results as any
await fetchSchedules() await fetchSchedules()
if (window.$showNotification) window.$showNotification(`批量保存完成:${results.filter(r => r.status === '排期已创建').length}/${results.length} 成功`, 'success') if ((window as any).$showNotification) (window as any).$showNotification(`批量保存完成:${results.filter(r => r.status === '排期已创建').length}/${results.length} 成功`, 'success')
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
if (window.$showNotification) window.$showNotification('批量排期失败:' + (e?.message || '未知错误'), 'error') if ((window as any).$showNotification) (window as any).$showNotification('批量排期失败:' + (e?.message || '未知错误'), 'error')
} finally { generatingSchedule.value = false } } finally { generatingSchedule.value = false }
} }
const handleDateChange = () => { if (!parsedSongs.value.length) fetchSchedules() } const handleDateChange = () => { if (!parsedSongs.value.length) fetchSchedules() }
watch(selectedDate, () => { if (!parsedSongs.value.length) fetchSchedules() }) if (import.meta.client) {
watch(selectedDate, () => { if (!parsedSongs.value.length) fetchSchedules() })
}
onMounted(() => { onMounted(() => {
if (!import.meta.client) return
fetchSchedules() fetchSchedules()
fetchPlayTimes() fetchPlayTimes()
}) })
</script> </script>
+20 -22
View File
@@ -285,6 +285,13 @@ export default defineNuxtConfig({
: process.env.NETLIFY : process.env.NETLIFY
? 'netlify' ? 'netlify'
: process.env.NITRO_PRESET || 'node-server', : process.env.NITRO_PRESET || 'node-server',
// 明确关闭 prerender / 爬取链接,避免 Node 端不必要的预渲染浪费内存
prerender: {
crawlLinks: false,
routes: ['/', '/login']
},
minify: true,
sourceMap: false,
// 增强错误处理和稳定性 // 增强错误处理和稳定性
experimental: { experimental: {
wasm: true, wasm: true,
@@ -388,13 +395,20 @@ export default defineNuxtConfig({
build: { build: {
chunkSizeWarningLimit: 2000, chunkSizeWarningLimit: 2000,
cssCodeSplit: true, cssCodeSplit: true,
sourcemap: false,
target: 'esnext',
reportCompressedSize: false,
rollupOptions: { rollupOptions: {
maxParallelFileOps: 2, maxParallelFileOps: 2,
cache: false,
output: { output: {
manualChunks(id) { manualChunks(id) {
if (id.includes('node_modules/lucide-vue-next')) { if (!id.includes('node_modules')) return
return 'lucide' if (id.includes('lucide-vue-next')) return 'icons'
} if (id.includes('@pixi')) return 'pixi'
if (id.includes('@applemusic-like-lyrics')) return 'lyric-engine'
if (id.includes('drizzle-orm') || id.includes('postgres')) return 'database'
if (id.includes('xlsx') || id.includes('jspdf') || id.includes('jszip')) return 'office'
} }
} }
} }
@@ -418,30 +432,14 @@ export default defineNuxtConfig({
include: ['drizzle-orm'], include: ['drizzle-orm'],
exclude: ['@applemusic-like-lyrics/vue', '@applemusic-like-lyrics/lyric'] exclude: ['@applemusic-like-lyrics/vue', '@applemusic-like-lyrics/lyric']
}, },
build: { // SSR配置: Vercel/Netlify serverless 需要 noExternal 把 drizzle/postgres inline;
sourcemap: false, // Node-server 环境直接从 node_modules require 即可, 避免打包 inline 导致 OOM
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return
if (id.includes('lucide-vue-next')) return 'icons'
if (id.includes('@pixi')) return 'pixi'
if (id.includes('@applemusic-like-lyrics')) return 'lyric-engine'
if (id.includes('drizzle-orm') || id.includes('postgres')) return 'database'
if (id.includes('xlsx') || id.includes('jspdf') || id.includes('jszip')) return 'office'
}
}
}
},
// 添加 WASM 支持配置
assetsInclude: ['**/*.wasm'],
// SSR配置
ssr: { ssr: {
noExternal: process.env.NETLIFY noExternal: process.env.NETLIFY
? ['drizzle-orm', 'postgres'] ? ['drizzle-orm', 'postgres']
: process.env.VERCEL : process.env.VERCEL
? [] ? []
: ['drizzle-orm', 'postgres'] : []
} }
} }
}) })