File size: 5,519 Bytes
00a912e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | import type { ClientToServerEvents, ServerToClientEvents } from '@music-together/shared'
import cors from 'cors'
import express from 'express'
import fs from 'node:fs'
import { createServer } from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { TypedServer } from './wss.js'
import { config } from './config.js'
import { initializeSocket } from './controllers/index.js'
import { identityHttpMiddleware } from './middleware/identityHttp.js'
import { attachSocketIdentity } from './middleware/socketIdentity.js'
import type { SocketData } from './middleware/types.js'
import authRoutes from './routes/auth.js'
import { createAdminRoutes } from './routes/admin.js'
import { createAccountRoutes } from './routes/account.js'
import musicRoutes from './routes/music.js'
import roomRoutes from './routes/rooms.js'
import settingsRoutes from './routes/settings.js'
import { clearAllTimers } from './services/roomLifecycleService.js'
import * as playerService from './services/playerService.js'
import {
startTencentCredentialRefreshScheduler,
stopTencentCredentialRefreshScheduler,
} from './services/tencentCredentialRefreshService.js'
import { logger } from './utils/logger.js'
import { databasePath } from './repositories/database.js'
const app = express()
const httpServer = createServer(app)
const io = new TypedServer<ClientToServerEvents, ServerToClientEvents, SocketData>(httpServer)
// HTTP API CORS: in auto mode we allow the browser-reported origin and rely on
// the cookie / same-host socket checks for deployment safety. This keeps local
// dev (localhost) and LAN access working consistently.
app.use(
cors({
origin: config.explicitOrigins.length > 0 ? config.explicitOrigins : (true as const),
credentials: true,
}),
)
// A 6MB image grows to roughly 8MB after the client encodes it as Base64.
// Route-level validation still enforces the 6MB original-image limit.
app.use(express.json({ limit: '9mb' }))
app.use('/api', identityHttpMiddleware)
app.use('/uploads/avatars', express.static(path.join(path.dirname(databasePath), 'avatars'), { maxAge: '1h' }))
app.use('/uploads/backgrounds', express.static(path.join(path.dirname(databasePath), 'backgrounds'), { maxAge: '1h' }))
// REST API routes
app.use('/api/auth', authRoutes)
app.use('/api/auth', createAccountRoutes(io))
app.use('/api/admin', createAdminRoutes(io))
app.use('/api/settings', settingsRoutes)
app.use('/api/music', musicRoutes)
app.use('/api/rooms', roomRoutes)
// Health check
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: Date.now() })
})
// Version check (client polls on startup to detect updates)
app.get('/api/version', (_req, res) => {
res.json({ version: config.version })
})
// --- Serve client SPA (条件挂载,仅当构建产物存在时) ---
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const clientDist = path.resolve(__dirname, '../../client/dist')
const indexHtml = path.join(clientDist, 'index.html')
if (fs.existsSync(indexHtml)) {
// Vite 产物带 content hash -> 长缓存
app.use(
'/assets',
express.static(path.join(clientDist, 'assets'), {
maxAge: '1y',
immutable: true,
}),
)
// 其他静态文件 (favicon, manifest 等)。index.html 不缓存,确保部署后立即生效
app.use(
express.static(clientDist, {
maxAge: '1h',
setHeaders: (res, filePath) => {
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate')
}
},
}),
)
// SPA fallback: 所有非 API 的 GET -> index.html
app.get('*', (_req, res) => {
res.setHeader('Cache-Control', 'no-cache, must-revalidate')
res.sendFile(indexHtml)
})
logger.info('客户端静态页面已加载', { clientDist })
} else {
logger.info('未发现客户端构建产物,已跳过静态页面托管(开发模式)')
}
attachSocketIdentity(io)
initializeSocket(io)
// Keep permanent-room playback position durable while a track is playing.
const playbackPersistenceTimer = setInterval(() => playerService.persistPlaybackSnapshots(), 5_000)
playbackPersistenceTimer.unref()
const tencentCredentialRefreshTimer = startTencentCredentialRefreshScheduler()
httpServer.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
logger.error(`Port ${config.port} already in use`)
process.exit(1)
}
throw err
})
httpServer.listen(config.port, () => {
logger.info(`服务器已启动,监听端口 ${config.port}`, {
event: 'server.started',
port: config.port,
environment: config.isProd ? 'production' : 'development',
version: config.version,
})
logger.info(
config.explicitOrigins.length > 0
? `仅允许以下来源连接:${config.explicitOrigins.join(', ')}`
: '当前允许所有来源连接(自动模式)',
)
})
// Graceful shutdown
function shutdown(signal: string) {
logger.info(`收到 ${signal} 信号,正在安全关闭服务器……`)
clearInterval(playbackPersistenceTimer)
stopTencentCredentialRefreshScheduler(tencentCredentialRefreshTimer)
playerService.persistPlaybackSnapshots()
clearAllTimers()
io.close(() => {
httpServer.close(() => {
logger.info('服务器已关闭')
process.exit(0)
})
})
// Force exit after 10s
setTimeout(() => process.exit(1), 10_000).unref()
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
|