koishi-plugin-chat-patch 5.6.1 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/client/web/dist/assets/Chat-BLcuHxzh.js +28 -0
  2. package/client/web/dist/assets/{Chat-BXVWj5Bd.css → Chat-Cee07eDL.css} +1 -1
  3. package/client/web/dist/assets/{MsgBody-CzrxyiFO.js → MsgBody-krZlLzRL.js} +1 -1
  4. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-CLmUjKws.js +67 -0
  5. package/client/web/dist/assets/{index-EJ2CcODr.js → index-DOJoilEy.js} +57 -60
  6. package/client/web/dist/index.html +1 -1
  7. package/client/web/src/assets/l10n/zh-CN.po +0 -3
  8. package/client/web/src/components/MsgBody.vue +29 -20
  9. package/client/web/src/function/connect.ts +17 -18
  10. package/client/web/src/function/msg.ts +2 -0
  11. package/client/web/src/function/satori-model.ts +2 -0
  12. package/client/web/src/function/satori.ts +18 -12
  13. package/client/web/src/function/utils/appUtil.ts +27 -6
  14. package/client/web/src/function/utils/msgUtil.ts +27 -21
  15. package/client/web/src/pages/Chat.vue +35 -32
  16. package/lib/database.d.ts +5 -3
  17. package/lib/gateway.d.ts +2 -4
  18. package/lib/index.d.ts +17 -0
  19. package/lib/index.js +5409 -473
  20. package/lib/media.d.ts +1 -1
  21. package/lib/recorder.d.ts +1 -4
  22. package/lib/self-message.d.ts +1 -4
  23. package/lib/types.d.ts +4 -0
  24. package/package.json +25 -10
  25. package/src/bootstrap.ts +3 -2
  26. package/src/config.ts +1 -5
  27. package/src/database.ts +313 -139
  28. package/src/gateway.ts +21 -13
  29. package/src/index.ts +59 -54
  30. package/src/media.ts +43 -12
  31. package/src/recorder.ts +2 -12
  32. package/src/self-message.ts +7 -14
  33. package/src/server.d.ts +17 -3
  34. package/src/types.ts +4 -0
  35. package/src/web.ts +245 -151
  36. package/client/web/dist/assets/Chat-m4-U5uHa.js +0 -28
  37. package/client/web/dist/assets/MsgBody.vue_vue_type_script_setup_true_lang-BBF1uxm6.js +0 -67
  38. package/lib/bootstrap.d.ts +0 -6
  39. package/lib/satori.d.ts +0 -3
  40. package/lib/web.d.ts +0 -7
package/src/web.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Context } from 'koishi'
2
2
  import {} from '@koishijs/plugin-server'
3
- import { createReadStream, existsSync, promises as fs, statSync } from 'node:fs'
3
+ import { createReadStream, createWriteStream, existsSync, promises as fs, statSync } from 'node:fs'
4
4
  import { createHash } from 'node:crypto'
5
5
  import path from 'node:path'
6
6
  import { pathToFileURL } from 'node:url'
7
- import send from 'koa-send'
8
- import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
7
+ import send from 'koa-send'
8
+ import type Router from '@koa/router'
9
+ import type { DefaultContext, DefaultState, ParameterizedContext } from 'koa'
9
10
  import FileType from 'file-type'
10
11
 
11
12
  import { Config } from './config'
@@ -13,7 +14,7 @@ import { ContactCacheService } from './cache'
13
14
  import { ChatDatabase } from './database'
14
15
  import { MediaManager } from './media'
15
16
  import { PluginLogger } from './logger'
16
- import { ContactCacheItem, SelfMessagePayload, SelfMessageRecord } from './types'
17
+ import { ContactCacheItem, MessageRecord, SelfMessagePayload, SelfMessageRecord } from './types'
17
18
 
18
19
  interface ViteConsoleServer {
19
20
  config?: {
@@ -39,10 +40,11 @@ export function registerWeb(
39
40
  const webRoot = path.resolve(__dirname, '..', 'client', 'web', 'dist')
40
41
  const webSource = path.resolve(__dirname, '..', 'client', 'web', 'src')
41
42
  const webPublic = path.resolve(__dirname, '..', 'client', 'web', 'public')
42
- const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
43
- const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
43
+ const webIndex = path.resolve(__dirname, '..', 'client', 'web', 'index.html')
44
+ const uploadDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'upload-media')
45
+ const mediaDir = path.resolve(ctx.baseDir, 'data', 'chat-patch', 'media')
44
46
 
45
- type WebContext = ParameterizedContext<DefaultState, DefaultContext>
47
+ type WebContext = Router.RouterContext<DefaultState, DefaultContext>
46
48
  const cacheType = (type: string) => type === 'user' ? 'friend' : type
47
49
 
48
50
  const getVite = (): ViteConsoleServer | undefined => {
@@ -105,6 +107,22 @@ export function registerWeb(
105
107
  const historyChannelCandidates = (channelId: string): string[] => {
106
108
  return channelId ? [channelId] : []
107
109
  }
110
+
111
+ const messageSortTime = (item: MessageRecord): number => {
112
+ const timestampMs = Number(item.timestampMs ?? 0)
113
+ if (timestampMs > 0) return timestampMs > 1e12 ? timestampMs : timestampMs * 1000
114
+ const timestamp = Number(item.timestamp ?? 0)
115
+ if (timestamp > 0) return timestamp * 1000
116
+ return Number(item.receivedAt ?? 0)
117
+ }
118
+
119
+ const selfMessageSortTime = (item: SelfMessageRecord): number => {
120
+ const timestampMs = Number(item.timestampMs ?? item.sentAt ?? 0)
121
+ if (timestampMs > 0) return timestampMs > 1e12 ? timestampMs : timestampMs * 1000
122
+ const timestamp = Number(item.timestamp ?? 0)
123
+ if (timestamp > 0) return timestamp * 1000
124
+ return item.sentAt
125
+ }
108
126
 
109
127
  const mimeToExt: Record<string, string> = {
110
128
  'audio/mpeg': '.mp3',
@@ -133,16 +151,21 @@ export function registerWeb(
133
151
  }
134
152
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
135
153
 
136
- ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
137
- const requestBody = (koa.request as unknown as { body?: unknown }).body
138
- const body = (requestBody ?? {}) as Record<string, unknown>
139
- let source = String(body.dataUrl ?? body.data ?? '')
140
- let name = String(body.name ?? '')
141
- let mime = ''
142
- let buffer: Buffer | null = null
143
- if (source) {
144
- if (source.startsWith('base64://')) source = source.slice(9)
145
- const comma = source.indexOf('base64,')
154
+ ctx.server.post(`${config.basePath}/api/upload-media`, async (koa) => {
155
+ const requestBody = (koa.request as unknown as { body?: unknown }).body
156
+ const body = (requestBody ?? {}) as Record<string, unknown>
157
+ let source = String(body.dataUrl ?? body.data ?? '')
158
+ let name = String(body.name ?? '')
159
+ let mime = ''
160
+ let buffer: Buffer | null = null
161
+ let tempPath = ''
162
+ const rawHash = createHash('md5')
163
+ const cleanupTemp = async () => {
164
+ if (tempPath) await fs.unlink(tempPath).catch(() => undefined)
165
+ }
166
+ if (source) {
167
+ if (source.startsWith('base64://')) source = source.slice(9)
168
+ const comma = source.indexOf('base64,')
146
169
  const base64 = comma >= 0 ? source.slice(comma + 7) : source
147
170
  const normalizedBase64 = base64.replace(/-/g, '+').replace(/_/g, '/')
148
171
  try {
@@ -152,124 +175,152 @@ export function registerWeb(
152
175
  koa.body = { error: 'invalid media data' }
153
176
  return
154
177
  }
155
- mime = source.startsWith('data:')
156
- ? source.slice(5, source.indexOf(';')).toLowerCase()
157
- : ''
158
- } else {
159
- const chunks: Buffer[] = []
160
- for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
161
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
162
- }
163
- buffer = Buffer.concat(chunks)
164
- name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
165
- try {
166
- name = decodeURIComponent(name)
178
+ mime = source.startsWith('data:')
179
+ ? source.slice(5, source.indexOf(';')).toLowerCase()
180
+ : ''
181
+ } else {
182
+ tempPath = path.join(uploadDir, `.upload-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`)
183
+ const write = createWriteStream(tempPath)
184
+ try {
185
+ for await (const chunk of koa.req as AsyncIterable<Buffer | string>) {
186
+ const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
187
+ rawHash.update(part)
188
+ if (!write.write(part)) {
189
+ await new Promise<void>((resolve) => write.once('drain', resolve))
190
+ }
191
+ }
192
+ await new Promise<void>((resolve, reject) => {
193
+ write.once('error', reject)
194
+ write.end(() => resolve())
195
+ })
196
+ } catch (error) {
197
+ write.destroy()
198
+ await cleanupTemp()
199
+ throw error
200
+ }
201
+ name = String(koa.query.name ?? koa.get('x-file-name') ?? '')
202
+ try {
203
+ name = decodeURIComponent(name)
167
204
  } catch {
168
205
  // 保持原始名称即可
169
206
  }
170
- mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
171
- }
172
- if (!buffer || !buffer.length) {
173
- koa.status = 400
174
- koa.body = { error: 'missing media data' }
175
- return
176
- }
177
- if (buffer.length > MAX_UPLOAD_BYTES) {
178
- koa.status = 413
179
- koa.body = { error: 'media file too large' }
180
- return
181
- }
182
- if (!buffer.length) {
183
- koa.status = 400
184
- koa.body = { error: 'invalid media data' }
185
- return
186
- }
187
-
188
- await fs.mkdir(uploadDir, { recursive: true })
189
- let detected: { ext?: string } | undefined
190
- try {
191
- const result = await FileType.fromBuffer(buffer)
192
- detected = result ?? undefined
193
- } catch {
194
- // 部分音频/文件无法识别时继续用 MIME 或文件名兜底
195
- detected = undefined
207
+ mime = String(koa.get('content-type') ?? '').split(';')[0].trim().toLowerCase()
208
+ }
209
+ const fileSize = buffer ? buffer.length : existsSync(tempPath) ? statSync(tempPath).size : 0
210
+ if (!fileSize) {
211
+ await cleanupTemp()
212
+ koa.status = 400
213
+ koa.body = { error: 'missing media data' }
214
+ return
215
+ }
216
+ if (fileSize > MAX_UPLOAD_BYTES) {
217
+ await cleanupTemp()
218
+ koa.status = 413
219
+ koa.body = { error: 'media file too large' }
220
+ return
221
+ }
222
+
223
+ await fs.mkdir(uploadDir, { recursive: true })
224
+ let detected: { ext?: string } | undefined
225
+ try {
226
+ const result = buffer
227
+ ? await FileType.fromBuffer(buffer)
228
+ : await FileType.fromStream(createReadStream(tempPath))
229
+ detected = result ?? undefined
230
+ } catch {
231
+ // 部分音频/文件无法识别时继续用 MIME 或文件名兜底
232
+ detected = undefined
196
233
  }
197
234
  const mimeExt = mimeToExt[mime] || ''
198
235
  const nameExt = path.extname(name).toLowerCase()
199
- const ext = detected?.ext
200
- ? toExtension(detected.ext)
201
- : mimeExt || nameExt || '.bin'
202
- const filename = `${createHash('md5').update(buffer).digest('hex')}${ext}`
203
- const filePath = path.join(uploadDir, filename)
204
- if (!existsSync(filePath)) {
205
- await fs.writeFile(filePath, buffer)
206
- }
207
- koa.body = {
208
- path: pathToFileURL(filePath).href,
236
+ const ext = detected?.ext
237
+ ? toExtension(detected.ext)
238
+ : mimeExt || nameExt || '.bin'
239
+ const digest = buffer ? createHash('md5').update(buffer).digest('hex') : rawHash.digest('hex')
240
+ const filename = `${digest}${ext}`
241
+ const filePath = path.join(uploadDir, filename)
242
+ try {
243
+ if (!existsSync(filePath)) {
244
+ if (buffer) {
245
+ await fs.writeFile(filePath, buffer)
246
+ } else {
247
+ await fs.rename(tempPath, filePath)
248
+ tempPath = ''
249
+ }
250
+ }
251
+ } catch (error) {
252
+ await cleanupTemp()
253
+ throw error
254
+ }
255
+ await cleanupTemp()
256
+ koa.body = {
257
+ path: pathToFileURL(filePath).href,
209
258
  localPath: filePath,
210
259
  }
211
260
  })
212
261
 
213
- ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
214
- const fileName = path.basename(String(koa.query.file ?? koa.query.name ?? ''))
215
- if (!fileName || fileName === '.' || fileName === '..') {
216
- koa.status = 400
217
- koa.body = { error: 'missing media file' }
218
- return
219
- }
220
- const filePath = path.resolve(uploadDir, fileName)
221
- const root = path.resolve(uploadDir)
222
- if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
223
- koa.status = 400
224
- koa.body = { error: 'invalid media file' }
225
- return
226
- }
227
- if (!existsSync(filePath) || !statSync(filePath).isFile()) {
228
- koa.status = 404
229
- koa.body = { error: 'media file not found' }
230
- return
231
- }
232
- const size = statSync(filePath).size
233
- koa.set('Accept-Ranges', 'bytes')
234
- if (fileName.toLowerCase().endsWith('.webm')) {
235
- koa.type = 'audio/webm'
236
- } else {
237
- koa.type = fileName
238
- }
239
- const range = koa.headers.range
240
- if (range) {
241
- const match = /^bytes=(\d*)-(\d*)$/.exec(String(range))
242
- if (!match) {
243
- koa.status = 416
244
- koa.set('Content-Range', `bytes */${size}`)
245
- koa.body = ''
246
- return
247
- }
248
- let start = 0
249
- let end = size - 1
250
- if (match[1] === '' && match[2]) {
251
- start = Math.max(0, size - Number(match[2]))
252
- } else if (match[1]) {
253
- start = Number(match[1])
254
- end = match[2] ? Number(match[2]) : end
255
- }
256
- if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
257
- koa.status = 416
258
- koa.set('Content-Range', `bytes */${size}`)
259
- koa.body = ''
260
- return
261
- }
262
- if (end >= size) end = size - 1
263
- if (end < start) end = start
264
- koa.status = 206
265
- koa.set('Content-Range', `bytes ${start}-${end}/${size}`)
266
- koa.set('Content-Length', String(end - start + 1))
267
- koa.body = createReadStream(filePath, { start, end })
268
- return
269
- }
270
- koa.set('Content-Length', String(size))
271
- koa.body = createReadStream(filePath)
272
- })
262
+ const sendLocalFile = async (koa: WebContext, filePath: string) => {
263
+ const size = statSync(filePath).size
264
+ const fileName = path.basename(filePath)
265
+ koa.set('Accept-Ranges', 'bytes')
266
+ if (fileName.toLowerCase().endsWith('.webm')) {
267
+ koa.type = 'audio/webm'
268
+ } else {
269
+ koa.type = fileName
270
+ }
271
+ const range = koa.headers.range
272
+ if (range) {
273
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(range))
274
+ if (!match) {
275
+ koa.status = 416
276
+ koa.set('Content-Range', `bytes */${size}`)
277
+ koa.body = ''
278
+ return
279
+ }
280
+ let start = 0
281
+ let end = size - 1
282
+ if (match[1] === '' && match[2]) {
283
+ start = Math.max(0, size - Number(match[2]))
284
+ } else if (match[1]) {
285
+ start = Number(match[1])
286
+ end = match[2] ? Number(match[2]) : end
287
+ }
288
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= size) {
289
+ koa.status = 416
290
+ koa.set('Content-Range', `bytes */${size}`)
291
+ koa.body = ''
292
+ return
293
+ }
294
+ if (end >= size) end = size - 1
295
+ if (end < start) end = start
296
+ koa.status = 206
297
+ koa.set('Content-Range', `bytes ${start}-${end}/${size}`)
298
+ koa.set('Content-Length', String(end - start + 1))
299
+ koa.body = createReadStream(filePath, { start, end })
300
+ return
301
+ }
302
+ koa.set('Content-Length', String(size))
303
+ koa.body = createReadStream(filePath)
304
+ }
305
+
306
+ ctx.server.get(`${config.basePath}/api/media`, async (koa) => {
307
+ const fileName = path.basename(String(koa.query.file ?? koa.query.name ?? ''))
308
+ if (!fileName || fileName === '.' || fileName === '..') {
309
+ koa.status = 400
310
+ koa.body = { error: 'missing media file' }
311
+ return
312
+ }
313
+ for (const root of [uploadDir, mediaDir]) {
314
+ const filePath = path.resolve(root, fileName)
315
+ if (filePath !== root && filePath.startsWith(`${root}${path.sep}`)
316
+ && existsSync(filePath) && statSync(filePath).isFile()) {
317
+ await sendLocalFile(koa, filePath)
318
+ return
319
+ }
320
+ }
321
+ koa.status = 404
322
+ koa.body = { error: 'media file not found' }
323
+ })
273
324
 
274
325
  ctx.server.get(`${config.basePath}/api/cache/all`, async (koa) => {
275
326
  const entries = await database.getAllContacts()
@@ -303,8 +354,12 @@ export function registerWeb(
303
354
  koa.body = { error: 'missing history params' }
304
355
  return
305
356
  }
306
- const parsedLimit = Number(koa.query.limit ?? config.maxMessagesPerChannel)
307
- const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : config.maxMessagesPerChannel
357
+ const defaultLimit = Math.min(config.maxMessagesPerChannel, 100)
358
+ const parsedLimit = Number(koa.query.limit ?? defaultLimit)
359
+ const limit = Math.min(
360
+ Math.max(1, Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.floor(parsedLimit) : defaultLimit),
361
+ config.maxMessagesPerChannel,
362
+ )
308
363
  const beforeTime = Number(koa.query.beforeTime ?? 0)
309
364
  const queryMessages = async (cid: string) => {
310
365
  return Number.isFinite(beforeTime) && beforeTime > 0
@@ -326,15 +381,40 @@ export function registerWeb(
326
381
  selfMessages = await querySelfMessages(candidate)
327
382
  if (selfMessages.length) break
328
383
  }
329
- // 统一按本地收到时间排序,避免平台时间不准导致顺序颠倒
330
- messages.sort((a, b) => {
331
- const timeA = Number(a?.receivedAt ?? a?.timestampMs ?? a?.timestamp ?? 0)
332
- const timeB = Number(b?.receivedAt ?? b?.timestampMs ?? b?.timestamp ?? 0)
333
- return timeA - timeB
334
- })
335
- selfMessages.sort((a, b) => a.sentAt - b.sentAt)
336
- koa.body = { messages, selfMessages }
337
- })
384
+ // 统一按本地收到时间排序,避免平台时间不准导致顺序颠倒
385
+ messages.sort((a, b) => {
386
+ const timeA = messageSortTime(a)
387
+ const timeB = messageSortTime(b)
388
+ return timeA - timeB
389
+ })
390
+ selfMessages.sort((a, b) => selfMessageSortTime(a) - selfMessageSortTime(b))
391
+ // 合并两条记录流后只返回 limit 条,保证前端分页/结束判断不会因自消息被放大
392
+ const combined: Array<{
393
+ kind: 'message' | 'self'
394
+ time: number
395
+ record: MessageRecord | SelfMessageRecord
396
+ }> = []
397
+ for (const item of messages) {
398
+ combined.push({
399
+ kind: 'message',
400
+ time: messageSortTime(item),
401
+ record: item,
402
+ })
403
+ }
404
+ for (const item of selfMessages) {
405
+ combined.push({ kind: 'self', time: selfMessageSortTime(item), record: item })
406
+ }
407
+ combined.sort((a, b) => a.time - b.time)
408
+ const capped = combined.slice(-limit)
409
+ koa.body = {
410
+ messages: capped
411
+ .filter((item) => item.kind === 'message')
412
+ .map((item) => item.record as MessageRecord),
413
+ selfMessages: capped
414
+ .filter((item) => item.kind === 'self')
415
+ .map((item) => item.record as SelfMessageRecord),
416
+ }
417
+ })
338
418
 
339
419
  ctx.server.get(`${config.basePath}/api/self-messages`, async (koa) => {
340
420
  const platform = String(koa.query.platform ?? '')
@@ -355,7 +435,7 @@ export function registerWeb(
355
435
  : await database.listSelfMessages(platform, selfId, candidate, limit)
356
436
  if (messages.length) break
357
437
  }
358
- messages.sort((a, b) => a.sentAt - b.sentAt)
438
+ messages.sort((a, b) => selfMessageSortTime(a) - selfMessageSortTime(b))
359
439
  koa.body = { messages }
360
440
  })
361
441
 
@@ -377,9 +457,15 @@ export function registerWeb(
377
457
  message: Array.isArray(body.message) ? body.message as unknown[] : undefined,
378
458
  forwardId: typeof body.forwardId === 'string' ? body.forwardId : undefined,
379
459
  forwardContent: Array.isArray(body.forwardContent) ? body.forwardContent as unknown[] : undefined,
380
- sentAt: typeof body.sentAt === 'number' && Number.isFinite(body.sentAt)
381
- ? body.sentAt
382
- : Date.now(),
460
+ sentAt: typeof body.sentAt === 'number' && Number.isFinite(body.sentAt)
461
+ ? body.sentAt
462
+ : Date.now(),
463
+ timestamp: typeof body.timestamp === 'number' && Number.isFinite(body.timestamp)
464
+ ? body.timestamp
465
+ : undefined,
466
+ timestampMs: typeof body.timestampMs === 'number' && Number.isFinite(body.timestampMs)
467
+ ? body.timestampMs
468
+ : undefined,
383
469
  sequence: typeof body.sequence === 'number' && Number.isFinite(body.sequence)
384
470
  ? body.sequence
385
471
  : 0,
@@ -395,7 +481,8 @@ export function registerWeb(
395
481
  koa.body = { error: 'missing self-message params' }
396
482
  return
397
483
  }
398
- const record: SelfMessageRecord = {
484
+ const sentAt = payload.sentAt ?? Date.now()
485
+ const record: SelfMessageRecord = {
399
486
  id: payload.id || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`,
400
487
  platform: payload.platform,
401
488
  selfId: payload.selfId,
@@ -408,7 +495,9 @@ export function registerWeb(
408
495
  message: payload.message,
409
496
  forwardId: payload.forwardId,
410
497
  forwardContent: payload.forwardContent,
411
- sentAt: payload.sentAt ?? Date.now(),
498
+ sentAt,
499
+ timestamp: payload.timestamp ?? Math.floor(sentAt / 1000),
500
+ timestampMs: payload.timestampMs ?? sentAt,
412
501
  sequence: payload.sequence ?? 0,
413
502
  source: payload.source ?? 'webui',
414
503
  kind: payload.kind ?? 'text',
@@ -685,15 +774,20 @@ export function registerWeb(
685
774
  })
686
775
 
687
776
  const serveFile = async (koa: WebContext, fileName: string) => {
688
- const fullPath = path.join(webRoot, fileName)
689
- if (existsSync(fullPath) && statSync(fullPath).isFile()) {
690
- await send(koa, path.relative(webRoot, fullPath), { root: webRoot })
691
- return
692
- }
693
- koa.status = 200
694
- koa.body = ''
695
- await send(koa, 'index.html', { root: webRoot })
696
- }
777
+ const fullPath = path.join(webRoot, fileName)
778
+ if (existsSync(fullPath) && statSync(fullPath).isFile()) {
779
+ const rootContext = koa as unknown as ParameterizedContext<DefaultState, DefaultContext>
780
+ await send(rootContext, path.relative(webRoot, fullPath), { root: webRoot })
781
+ return
782
+ }
783
+ koa.status = 200
784
+ koa.body = ''
785
+ await send(
786
+ koa as unknown as ParameterizedContext<DefaultState, DefaultContext>,
787
+ 'index.html',
788
+ { root: webRoot },
789
+ )
790
+ }
697
791
 
698
792
  const serveDevWeb = async (koa: WebContext): Promise<boolean> => {
699
793
  const vite = getVite()