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'))