import { createReadStream, existsSync, statSync, cpSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import vue from '@vitejs/plugin-vue'
// kit.css ссылается на шрифты/иконки через абсолютные /assets/... URL —
// по контракту кита хост обязан отдавать его dist/assets на /assets.
// В dev — middleware, в build — копирование в dist/assets.
const kitAssetsDir = path.resolve(
fileURLToPath(new URL('.', import.meta.url)),
'node_modules/gnexus-ui-kit/dist/assets',
)
const mimeByExt: Record<string, string> = {
'.css': 'text/css',
'.js': 'text/javascript',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
}
function kitAssetsPlugin(): Plugin {
return {
name: 'gnexus-kit-assets',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = (req.url ?? '').split('?')[0]
if (!url.startsWith('/assets/')) return next()
const file = path.join(kitAssetsDir, url.slice('/assets/'.length))
if (!existsSync(file) || !statSync(file).isFile()) return next()
res.setHeader('Content-Type', mimeByExt[path.extname(file)] ?? 'application/octet-stream')
createReadStream(file).pipe(res)
})
},
closeBundle() {
cpSync(kitAssetsDir, path.resolve(fileURLToPath(new URL('.', import.meta.url)), 'dist/assets'), {
recursive: true,
})
},
}
}
// Dev: /api и /auth проксируются на backend. В проде nginx отдаёт статику и проксирует сам.
export default defineConfig({
plugins: [
vue(),
kitAssetsPlugin(),
// PWA (M6): manifest + precache статики приложения.
// Оффлайн-режим данных (кэш /api) — открытый вопрос ТЗ 8.4, пока всегда онлайн.
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'gntodo',
short_name: 'gntodo',
description: 'Персональный менеджер задач',
lang: 'ru',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#1f9d6c',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{
src: '/icons/maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
workbox: {
// CSS кита/phosphor ~4 МБ — поднимаем лимит precache, иначе оффлайн-оболочка неполна
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
// /api и /auth никогда не кэшируются и не получают fallback на index.html
navigateFallbackDenylist: [/^\/api\//, /^\/auth\//, /^\/mcp\//],
runtimeCaching: [
{
// вложения — только из кэша, если попали туда ранее; сеть не дублируем
urlPattern: ({ url, request }) =>
(url.pathname.startsWith('/api/attachments/') ||
url.pathname.startsWith('/assets/')) &&
request.method === 'GET',
handler: 'CacheFirst',
options: {
cacheName: 'attachments-and-assets',
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
}),
],
server: {
port: 15134, // 5173 занят другими проектами; мнемоника: 1 + API 8134
proxy: {
'/api': {
target: 'http://localhost:8134',
changeOrigin: true,
},
'/auth': {
target: 'http://localhost:8134',
changeOrigin: true,
},
},
},
})