diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index f551ca5ce..9c530944a 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -110,8 +110,8 @@ jobs: NPM_CONFIG_IGNORE_SCRIPTS: "true" working-directory: frontend run: | - mkdir -p src/assets - npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json + mkdir -p editor/src/assets + npx --yes license-report --only=prod --output=json > editor/src/assets/3rdPartyLicenses.json - name: Postprocess with project script (BASE version) if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true diff --git a/.gitignore b/.gitignore index fb4c7b642..3e8d62d65 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,8 @@ app/core/src/main/resources/static/robots.txt app/core/src/main/resources/static/pdfium/ app/core/src/main/resources/static/pdfjs/ app/core/src/main/resources/static/vendor/ +app/core/src/main/resources/static/**/*.gz +app/core/src/main/resources/static/**/*.br # Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc. # Gradle diff --git a/app/core/build.gradle b/app/core/build.gradle index 54bdd7bd7..7f2fe4dc7 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -207,13 +207,29 @@ def resourcesStaticDir = file('src/main/resources/static') def generatedFrontendPaths = [ 'assets', 'index.html', + 'index.html.gz', + 'index.html.br', + 'sw.js', + 'sw.js.gz', + 'sw.js.br', + 'manifest.json.gz', + 'manifest.json.br', + 'site.webmanifest.gz', + 'site.webmanifest.br', + 'browserconfig.xml.gz', + 'browserconfig.xml.br', + 'manifest-classic.json', + 'manifest-classic.json.gz', + 'manifest-classic.json.br', 'locales', 'Login', 'classic-logo', 'modern-logo', 'og_images', 'samples', - 'manifest-classic.json' + 'pdfium', + 'vendor', + 'pdfjs' ] tasks.register('npmInstall', Exec) { diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 48fecf001..367c87574 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -13,6 +13,7 @@ import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.EncodedResourceResolver; import lombok.RequiredArgsConstructor; @@ -49,14 +50,16 @@ public class WebMvcConfig implements WebMvcConfigurer { "/sw.js", "/manifest.json", "/site.webmanifest", "/browserconfig.xml") .addResourceLocations(staticPath, "classpath:/static/") .setCacheControl(CacheControl.noStore()) - .resourceChain(true); + .resourceChain(true) + .addResolver(new EncodedResourceResolver()); // 2. Vite fingerprinted assets (immutable) // These already have content hashes in filenames (e.g. index-ChAS4tCC.js) registry.addResourceHandler("/assets/**") .addResourceLocations(staticPath + "assets/", "classpath:/static/assets/") .setCacheControl(IMMUTABLE_ONE_YEAR) - .resourceChain(true); + .resourceChain(true) + .addResolver(new EncodedResourceResolver()); // 3. Media and fonts (immutable) registry.addResourceHandler("/images/**", "/fonts/**") @@ -66,7 +69,8 @@ public class WebMvcConfig implements WebMvcConfigurer { staticPath + "fonts/", "classpath:/static/fonts/") .setCacheControl(IMMUTABLE_ONE_YEAR) - .resourceChain(true); + .resourceChain(true) + .addResolver(new EncodedResourceResolver()); // 4. Branding and stable non-fingerprinted assets (1 day + SWR) // Use stale-while-revalidate to improve perceived performance. @@ -114,19 +118,27 @@ public class WebMvcConfig implements WebMvcConfigurer { staticPath + "og_images/", "classpath:/static/og_images/", staticPath + "Login/", - "classpath:/static/Login/") + "classpath:/static/Login/", + staticPath + "icons/", + "classpath:/static/icons/", + staticPath + "modern-logo/", + "classpath:/static/modern-logo/", + staticPath + "classic-logo/", + "classpath:/static/classic-logo/") .setCacheControl( CacheControl.maxAge(Duration.ofDays(1)) .cachePublic() .staleWhileRevalidate(Duration.ofDays(7))) - .resourceChain(true); + .resourceChain(true) + .addResolver(new EncodedResourceResolver()); // 5. Catch-all (SPA fallback) // Must check with server to ensure index.html is always fresh. registry.addResourceHandler("/**") .addResourceLocations(staticPath, "classpath:/static/") .setCacheControl(NO_CACHE) - .resourceChain(true); + .resourceChain(true) + .addResolver(new EncodedResourceResolver()); } @Override diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index b5777e1a1..fc2fda630 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -33,7 +33,7 @@ spring.security.filter.dispatcher-types=REQUEST,ERROR # Response compression server.compression.enabled=true server.compression.min-response-size=1024 -server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript,image/svg+xml,application/x-font-ttf,font/opentype,application/vnd.ms-fontobject,font/woff,font/woff2,application/font-woff,application/font-woff2 +server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript,image/svg+xml,application/x-font-ttf,font/opentype,application/vnd.ms-fontobject,font/woff,font/woff2,application/font-woff,application/font-woff2,application/wasm spring.web.error.path=/error spring.web.error.whitelabel.enabled=false diff --git a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx index 11f9decee..92f025efc 100644 --- a/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/editor/src/core/components/viewer/LocalEmbedPDF.tsx @@ -89,7 +89,7 @@ import { RedactionAPIBridge } from "@app/components/viewer/RedactionAPIBridge"; import { DocumentPermissionsAPIBridge } from "@app/components/viewer/DocumentPermissionsAPIBridge"; import { DocumentReadyWrapper } from "@app/components/viewer/DocumentReadyWrapper"; import { ActiveDocumentProvider } from "@app/components/viewer/ActiveDocumentContext"; -import { absoluteWithBasePath } from "@app/constants/app"; +import { pdfiumWasmUrl } from "@app/services/wasmPrecompiler"; import { FormFieldOverlay } from "@app/tools/formFill/FormFieldOverlay"; import { ButtonAppearanceOverlay } from "@app/tools/formFill/ButtonAppearanceOverlay"; import SignatureFieldOverlay from "@app/components/viewer/SignatureFieldOverlay"; @@ -296,7 +296,7 @@ export function LocalEmbedPDF({ // Initialize the engine with the React hook - use local WASM for offline support const { engine, isLoading, error } = usePdfiumEngine({ - wasmUrl: absoluteWithBasePath("/pdfium/pdfium.wasm"), + wasmUrl: pdfiumWasmUrl, }); // Early return if no file or URL provided diff --git a/frontend/editor/src/core/services/pdfiumService.ts b/frontend/editor/src/core/services/pdfiumService.ts index b53414074..107e36d8c 100644 --- a/frontend/editor/src/core/services/pdfiumService.ts +++ b/frontend/editor/src/core/services/pdfiumService.ts @@ -12,9 +12,34 @@ * `getSignatures`, `saveAsCopy`, …) wrap the `PdfEngine` interface so callers * never have to deal with raw pointers or Tasks. */ -import { init, type WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { + init, + type WrappedPdfiumModule, + type PdfiumModule, +} from "@embedpdf/pdfium"; +import { + pdfiumWasmModulePromise, + startEagerWasmCompilation, + pdfiumWasmUrl, +} from "@app/services/wasmPrecompiler"; import type { FormField, WidgetCoordinates } from "@app/tools/formFill/types"; +interface ExtendedPdfiumRuntime { + HEAPU8: Uint8Array; + HEAPF32: Float32Array; +} + +interface PdfiumModuleOverrides extends Partial { + locateFile?: (url: string, scriptDirectory?: string) => string; + instantiateWasm?: ( + imports: WebAssembly.Imports, + successCallback: ( + instance: WebAssembly.Instance, + module: WebAssembly.Module, + ) => void, + ) => void; +} + // PDF form field type constants (matching PDFium C API FPDF_FORMFIELD_* values) const FPDF_FORMFIELD_UNKNOWN = 0; const FPDF_FORMFIELD_PUSHBUTTON = 1; @@ -46,8 +71,7 @@ let _module: WrappedPdfiumModule | null = null; * Resolve the absolute WASM URL using the same pattern as LocalEmbedPDF. */ function wasmUrl(): string { - const base = (import.meta as any).env?.BASE_URL ?? "/"; - return `${base}pdfium/pdfium.wasm`.replace(/\/\//g, "/"); + return pdfiumWasmUrl; } /** @@ -59,9 +83,47 @@ function wasmUrl(): string { export async function getPdfiumModule(): Promise { if (_module) return _module; if (!_initPromise) { - _initPromise = init({ + // Ensure eager compilation has started if PDF service is requested before idle timeout + startEagerWasmCompilation(); + + const overrides: PdfiumModuleOverrides = { locateFile: () => wasmUrl(), - } as any).then((m) => { + }; + + // Eagerly reuse pre-compiled WASM module from app boot if available + overrides.instantiateWasm = ( + imports: WebAssembly.Imports, + successCallback: ( + instance: WebAssembly.Instance, + module: WebAssembly.Module, + ) => void, + ) => { + pdfiumWasmModulePromise + .then((wasmModule) => { + if (wasmModule) { + return WebAssembly.instantiate(wasmModule, imports).then( + (instance) => { + successCallback(instance, wasmModule); + }, + ); + } else { + throw new Error("No pre-compiled WASM module found"); + } + }) + .catch((err: unknown) => { + console.warn( + "Eager WebAssembly instantiation failed, falling back to streaming compilation:", + err, + ); + WebAssembly.instantiateStreaming(fetch(wasmUrl()), imports).then( + (result) => { + successCallback(result.instance, result.module); + }, + ); + }); + }; + + _initPromise = init(overrides as Partial).then((m) => { // Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up try { m.PDFiumExt_Init(); @@ -110,9 +172,9 @@ export function readAnnotRectAdjusted( annotPtr: number, rectBuf: number, ): boolean { - const ext = (m as any).EPDFAnnot_GetRect; + const ext = m.EPDFAnnot_GetRect; if (typeof ext === "function") { - return ext.call(m, annotPtr, rectBuf); + return ext(annotPtr, rectBuf); } return m.FPDFAnnot_GetRect(annotPtr, rectBuf); } @@ -186,15 +248,13 @@ export function readEffectivePageBox( let result: PageBox | null = null; try { // CropBox is the effective visible area - if ( - (m as any).FPDFPage_GetCropBox(pagePtr, buf, buf + 4, buf + 8, buf + 12) - ) { + if (m.FPDFPage_GetCropBox(pagePtr, buf, buf + 4, buf + 8, buf + 12)) { result = read(); } // Fall back to MediaBox if ( !result && - (m as any).FPDFPage_GetMediaBox(pagePtr, buf, buf + 4, buf + 8, buf + 12) + m.FPDFPage_GetMediaBox(pagePtr, buf, buf + 4, buf + 8, buf + 12) ) { result = read(); } @@ -226,7 +286,7 @@ function copyToWasmHeap( bytes: Uint8Array, ptr: number, ): void { - new Uint8Array((m.pdfium.wasmExports as any).memory.buffer).set(bytes, ptr); + (m.pdfium as typeof m.pdfium & ExtendedPdfiumRuntime).HEAPU8.set(bytes, ptr); } /** @@ -1338,9 +1398,13 @@ async function renderWidgetAppearance( formEnvPtr: number, dpr: number, ): Promise { - const pdfiumWasm = m.pdfium as any; const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4); - const matrixView = new Float32Array(pdfiumWasm.HEAPF32.buffer, matrixPtr, 6); + const pdfiumRuntime = m.pdfium as typeof m.pdfium & ExtendedPdfiumRuntime; + const matrixView = new Float32Array( + pdfiumRuntime.HEAPF32.buffer, + matrixPtr, + 6, + ); const sx = wDev / pdfW; const sy = hDev / pdfH; matrixView.set([sx, 0, 0, -sy, -sx * annotLeft, sy * annotTop]); @@ -1364,7 +1428,7 @@ async function renderWidgetAppearance( let imageData: ImageData | null = null; if (ok) { const rgba = new Uint8ClampedArray( - pdfiumWasm.HEAPU8.buffer.slice(heapPtr, heapPtr + bytes), + pdfiumRuntime.HEAPU8.subarray(heapPtr, heapPtr + bytes), ); let hasVisible = false; for (let i = 3; i < rgba.length; i += 4) { @@ -1417,7 +1481,7 @@ async function renderWidgetAppearance( m.FPDFBitmap_Destroy(bmp2); const rgba2 = new Uint8ClampedArray( - pdfiumWasm.HEAPU8.buffer.slice(heap2, heap2 + bytes), + pdfiumRuntime.HEAPU8.subarray(heap2, heap2 + bytes), ); let hasVisible2 = false; for (let i = 3; i < rgba2.length; i += 4) { @@ -1560,8 +1624,6 @@ export async function renderSignatureFieldAppearances( const hDev = Math.max(1, Math.round(pdfH * dpr)); const stride = wDev * 4; const bytes = stride * hDev; - const pdfiumWasm = m.pdfium as any; - const heapPtr = m.pdfium.wasmExports.malloc(bytes); const bitmapPtr = m.FPDFBitmap_CreateEx( wDev, @@ -1577,8 +1639,10 @@ export async function renderSignatureFieldAppearances( const sx = wDev / pdfW; const sy = hDev / pdfH; const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4); + const pdfiumRuntime = m.pdfium as typeof m.pdfium & + ExtendedPdfiumRuntime; const matrixView = new Float32Array( - pdfiumWasm.HEAPF32.buffer, + pdfiumRuntime.HEAPF32.buffer, matrixPtr, 6, ); @@ -1603,7 +1667,7 @@ export async function renderSignatureFieldAppearances( if (ok) { const rgba = new Uint8ClampedArray( - pdfiumWasm.HEAPU8.buffer.slice(heapPtr, heapPtr + bytes), + pdfiumRuntime.HEAPU8.subarray(heapPtr, heapPtr + bytes), ); let hasVisible = false; for (let i = 3; i < rgba.length; i += 4) { @@ -1663,7 +1727,7 @@ export async function renderSignatureFieldAppearances( m.FPDFBitmap_Destroy(bmp2); const rgba2 = new Uint8ClampedArray( - pdfiumWasm.HEAPU8.buffer.slice(heap2, heap2 + bytes), + pdfiumRuntime.HEAPU8.subarray(heap2, heap2 + bytes), ); let hasVisible2 = false; for (let i = 3; i < rgba2.length; i += 4) { diff --git a/frontend/editor/src/core/services/wasmPrecompiler.ts b/frontend/editor/src/core/services/wasmPrecompiler.ts new file mode 100644 index 000000000..ff84010e4 --- /dev/null +++ b/frontend/editor/src/core/services/wasmPrecompiler.ts @@ -0,0 +1,54 @@ +import { BASE_PATH } from "@app/constants/app"; +import pdfiumWasmAssetUrl from "@embedpdf/pdfium/pdfium.wasm?url"; + +const getWasmUrl = (): string => { + if ( + pdfiumWasmAssetUrl.startsWith("http://") || + pdfiumWasmAssetUrl.startsWith("https://") || + pdfiumWasmAssetUrl.startsWith("//") + ) { + return pdfiumWasmAssetUrl; + } + + const origin = typeof window !== "undefined" ? window.location.origin : ""; + if (import.meta.env.DEV) { + return `${origin}${BASE_PATH}/pdfium/pdfium.wasm`; + } + const cleanAssetUrl = pdfiumWasmAssetUrl + .replace(/^\.\//, "") + .replace(/^\//, ""); + return `${origin}${BASE_PATH}/${cleanAssetUrl}`; +}; + +export const pdfiumWasmUrl = getWasmUrl(); + +let resolvePromise: (module: WebAssembly.Module | null) => void; +let compilationStarted = false; + +export const pdfiumWasmModulePromise = new Promise( + (resolve) => { + resolvePromise = resolve; + }, +); + +export function startEagerWasmCompilation(): void { + if (compilationStarted) return; + compilationStarted = true; + + if ( + typeof WebAssembly === "object" && + typeof WebAssembly.compileStreaming === "function" + ) { + WebAssembly.compileStreaming(fetch(pdfiumWasmUrl)) + .then(resolvePromise) + .catch((err) => { + console.warn( + "Eager WASM compilation failed or not supported in this environment:", + err, + ); + resolvePromise(null); + }); + } else { + resolvePromise(null); + } +} diff --git a/frontend/editor/src/index.tsx b/frontend/editor/src/index.tsx index 251f42e1f..017138ceb 100644 --- a/frontend/editor/src/index.tsx +++ b/frontend/editor/src/index.tsx @@ -17,6 +17,24 @@ import posthog from "posthog-js"; import { PostHogProvider } from "@posthog/react"; import { BASE_PATH } from "@app/constants/app"; +import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler"; + +if (typeof window !== "undefined") { + const scheduleCompilation = () => { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => startEagerWasmCompilation(), { timeout: 2000 }); + } else { + setTimeout(startEagerWasmCompilation, 1000); + } + }; + + if (document.readyState === "complete") { + scheduleCompilation(); + } else { + window.addEventListener("load", scheduleCompilation); + } +} + posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, defaults: "2025-05-24", diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 40b81b02c..3f51f7ba3 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -2,6 +2,76 @@ import { defineConfig, loadEnv, type PluginOption } from "vite"; import react from "@vitejs/plugin-react-swc"; import tsconfigPaths from "vite-tsconfig-paths"; import { viteStaticCopy } from "vite-plugin-static-copy"; +import { compression, defineAlgorithm } from "vite-plugin-compression2"; +import { constants, gzip, brotliCompress } from "node:zlib"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const gzipPromise = promisify(gzip); +const brotliPromise = promisify(brotliCompress); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function compressStaticCopyPlugin(): PluginOption { + return { + name: "compress-static-copy", + apply: "build" as const, + async closeBundle() { + const distDir = path.resolve(__dirname, "dist"); + const targets = ["pdfium", "vendor", "pdfjs"]; + + const excludedExtensions = [ + ".gz", + ".br", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".woff", + ".woff2", + ]; + + async function walkAndCompress(dirOrFile: string) { + let stat; + try { + stat = await fs.stat(dirOrFile); + } catch { + return; + } + + if (stat.isFile()) { + const ext = path.extname(dirOrFile).toLowerCase(); + if (stat.size >= 1024 && !excludedExtensions.includes(ext)) { + const content = await fs.readFile(dirOrFile); + + // Gzip (level 9) + const gzipped = await gzipPromise(content, { level: 9 }); + await fs.writeFile(`${dirOrFile}.gz`, gzipped); + + // Brotli (quality 11) + const brotlied = await brotliPromise(content, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 11, + }, + }); + await fs.writeFile(`${dirOrFile}.br`, brotlied); + } + } else if (stat.isDirectory()) { + const files = await fs.readdir(dirOrFile); + for (const file of files) { + await walkAndCompress(path.join(dirOrFile, file)); + } + } + } + + for (const target of targets) { + await walkAndCompress(path.join(distDir, target)); + } + }, + }; +} const VALID_MODES = [ "core", @@ -78,6 +148,18 @@ export default defineConfig(async ({ mode }) => { tsconfigPaths({ projects: [tsconfigProject], }), + compression({ + threshold: 1024, + exclude: [/\.(png|jpg|jpeg|gif|webp|woff|woff2)$/], + algorithms: [ + defineAlgorithm("gzip", { level: 9 }), + defineAlgorithm("brotliCompress", { + params: { + [constants.BROTLI_PARAM_QUALITY]: 11, + }, + }), + ], + }), // Set ANALYZE=true to emit dist/stats.html (treemap) alongside the // build; rollup-plugin-visualizer is ESM-only so we import dynamically. ...(process.env.ANALYZE === "true" @@ -119,6 +201,7 @@ export default defineConfig(async ({ mode }) => { }, ], }), + compressStaticCopyPlugin(), ], server: { host: true, @@ -139,6 +222,20 @@ export default defineConfig(async ({ mode }) => { strictPort: true, proxy: backendProxyConfig, }, + build: { + target: "esnext", + rollupOptions: { + output: { + manualChunks: { + "vendor-react": ["react", "react-dom"], + "pdf-engine": ["@embedpdf/engines", "@embedpdf/pdfium"], + }, + }, + }, + }, + optimizeDeps: { + exclude: ["@embedpdf/pdfium"], + }, // base: "./" produces relative asset URLs which work when dist/ is served // at any path (e.g. Spring Boot bundling the frontend at /). But under // `vite preview` for deep SPA routes (e.g. /workflow/sign/), the diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5c67488af..c2a89e10d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -132,6 +132,7 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.3.2", + "vite-plugin-compression2": "^2.5.3", "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" @@ -16503,6 +16504,13 @@ "bare-path": "^3.0.0" } }, + "node_modules/tar-mini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/tar-mini/-/tar-mini-0.2.0.tgz", + "integrity": "sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tar-stream": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", @@ -17412,6 +17420,17 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-compression2": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/vite-plugin-compression2/-/vite-plugin-compression2-2.5.3.tgz", + "integrity": "sha512-ItPgqQWkcnBbVw7is9OKwiZ8v6+ju9rYROl5Lp6QfQDEx/d55AwJQb/KLpsQqsU9HoigYBsZ8tK6I02UwJNvEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "tar-mini": "^0.2.0" + } + }, "node_modules/vite-plugin-static-copy": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8803e14ff..7d529c9d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -152,6 +152,7 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.3.2", + "vite-plugin-compression2": "^2.5.3", "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4"