2022-12-20 20:47:51 +05:30
import { defineConfig } from 'vite' ;
import react from '@vitejs/plugin-react' ;
import { wasm } from '@rollup/plugin-wasm' ;
2026-05-22 14:17:15 -04:00
import inject from '@rollup/plugin-inject' ;
2022-12-20 20:47:51 +05:30
import { viteStaticCopy } from 'vite-plugin-static-copy' ;
2024-05-12 14:06:53 +10:00
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin' ;
2024-09-07 21:45:55 +08:00
import { VitePWA } from 'vite-plugin-pwa' ;
2025-05-18 10:53:56 +05:30
import fs from 'fs' ;
import path from 'path' ;
2024-05-12 14:06:53 +10:00
import buildConfig from './build.config' ;
2022-12-20 20:47:51 +05:30
const copyFiles = {
targets : [
2026-03-07 18:03:32 +11:00
{
2026-06-13 12:26:46 -04:00
// Element Call's dist must land flat in public/element-call/ so the call
// widget URL (/public/element-call/index.html) resolves. v4.x of
// vite-plugin-static-copy preserves the full source path under dest, so
// we strip the 4 leading segments of the source base
2026-06-30 01:33:52 -04:00
// (node_modules/@lotusguild/element-call-embedded/dist) — mirroring the
2026-06-13 12:26:46 -04:00
// stripBase pattern used by the android/locales targets below. The old
// `rename: 'element-call'` form silently produced
// public/node_modules/.../dist/ under v4.x, 404ing the widget (calls
// broke on cinny-desktop; web only worked because its deployed copy was
// a stale artifact from before the vite-plugin-static-copy v4 bump).
2026-06-30 01:33:52 -04:00
// Source is our self-built fork (LotusGuild/element-call) published to
// the Gitea npm registry; see HANDOFF_ELEMENT_CALL_FORK.md.
src : 'node_modules/@lotusguild/element-call-embedded/dist' ,
2026-06-13 12:26:46 -04:00
dest : 'public/element-call' ,
rename : { stripBase : 4 },
2026-03-07 18:03:32 +11:00
},
2022-12-20 20:47:51 +05:30
{
src : 'config.json' ,
dest : '' ,
},
2023-08-03 06:23:28 +02:00
{
src : 'public/manifest.json' ,
dest : '' ,
2026-05-22 00:36:30 -04:00
rename : { stripBase : true },
2023-08-03 06:23:28 +02:00
},
2022-12-20 20:47:51 +05:30
{
2026-07-07 20:28:40 -04:00
// All of public/res/ (android + apple icons, logos, svg) → dist/public/res/,
// matching the /public/res/... paths used by index.html and manifest.json.
// (The old android-only target stripped `res/` and landed at /public/android/,
// so every icon 404'd — apple/ + favicon weren't copied at all.)
src : 'public/res' ,
2022-12-20 20:47:51 +05:30
dest : 'public/' ,
2026-07-07 20:28:40 -04:00
rename : { stripBase : 1 },
},
{
src : 'public/favicon.ico' ,
dest : 'public/' ,
rename : { stripBase : 1 },
2023-08-03 06:23:28 +02:00
},
2024-08-14 15:29:34 +02:00
{
src : 'public/locales' ,
dest : 'public/' ,
2026-05-22 00:36:30 -04:00
rename : { stripBase : 1 },
2024-08-14 15:29:34 +02:00
},
2026-06-14 14:17:36 -04:00
{
src : 'public/fonts' ,
dest : '' ,
rename : { stripBase : 1 },
},
2022-12-20 20:47:51 +05:30
],
2024-05-12 14:06:53 +10:00
};
2022-12-20 20:47:51 +05:30
2026-05-21 21:45:30 -04:00
function copyPdfWorker () {
return {
name : 'copy-pdf-worker' ,
closeBundle () {
2026-07-01 22:53:32 -04:00
// Never throw from here: closeBundle also runs when the build FAILED
// mid-render (dist/ absent) and an exception here MASKS the real build
// error in vite's report (seen on the Windows CI runner). Warn and skip.
try {
const src = path . resolve ( 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs' );
const dest = path . resolve ( 'dist/pdf.worker.min.js' );
if ( ! fs . existsSync ( src )) {
console . warn ( '[copy-pdf-worker] source worker missing, skipped:' , src );
return ;
}
fs . mkdirSync ( path . dirname ( dest ), { recursive : true });
fs . copyFileSync ( src , dest );
} catch ( err ) {
console . warn ( '[copy-pdf-worker] skipped:' , err ? . message ?? err );
}
2026-05-21 21:45:30 -04:00
},
};
}
2026-10-18 00:24:03 -04:00
// The denoise assets the fork loads from ./denoise/ at runtime, as
// [source, path under denoise/] pairs; `dir: true` entries are whole trees.
// Shared by the build copy and the dev server (Gitea #293).
function denoiseSources () {
const sapphi = path . resolve ( 'node_modules/@sapphi-red/web-noise-suppressor/dist' );
const dfnVendor = path . resolve ( 'build/denoise-vendor/deepfilternet' );
return {
// All bundled denoise assets are REQUIRED: every entry backs a model the
// UI can select (RNNoise, Speex) or the optional noise gate.
sapphi : [
[ path . join ( sapphi , 'rnnoise/workletProcessor.js' ), 'rnnoiseWorklet.js' ],
[ path . join ( sapphi , 'rnnoise.wasm' ), 'rnnoise.wasm' ],
[ path . join ( sapphi , 'rnnoise_simd.wasm' ), 'rnnoise_simd.wasm' ],
[ path . join ( sapphi , 'speex/workletProcessor.js' ), 'speexWorklet.js' ],
[ path . join ( sapphi , 'speex.wasm' ), 'speex.wasm' ],
[ path . join ( sapphi , 'noiseGate/workletProcessor.js' ), 'noiseGateWorklet.js' ],
],
// DTLN (@workadventure/noise-suppression): a self-contained ES module that
// resolves its own worklet, LiteRT WASM runtime and TFLite models via
// `import.meta.url`, so its whole dist/ tree is served intact.
dtln : [ path . resolve ( 'node_modules/@workadventure/noise-suppression/dist' ), 'workadventure' ],
// DeepFilterNet 3: the ESM (worklet inlined) plus the vendored wasm and ONNX
// model (CDN-free / Tauri-CSP safe), in the package's expected v2/ layout.
dfn : [
[
path . resolve ( 'node_modules/deepfilternet3-noise-filter/dist/index.esm.js' ),
'deepfilternet/index.esm.js' ,
],
[ path . join ( dfnVendor , 'v2/pkg/df_bg.wasm' ), 'deepfilternet/v2/pkg/df_bg.wasm' ],
[
path . join ( dfnVendor , 'v2/models/DeepFilterNet3_onnx.tar.gz' ),
'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz' ,
],
],
};
}
// Lotus ML noise suppression: ship the denoise worklets/wasm/models alongside
// the vendored Element Call bundle. Runs after viteStaticCopy has populated
// dist/public/element-call (closeBundle order). [Gitea #293] The dev server
// serves the same files at the same URLs, so `npm start` can test denoise.
2026-06-15 20:29:59 -04:00
function lotusDenoise () {
2026-10-18 00:24:03 -04:00
const DEV_PREFIX = '/public/element-call/denoise/' ;
2026-06-15 20:29:59 -04:00
return {
name : 'lotus-denoise' ,
2026-10-18 00:24:03 -04:00
configureServer ( server ) {
const { sapphi , dtln , dfn } = denoiseSources ();
const files = new Map ([... sapphi , ... dfn ]. map (([ src , rel ]) => [ rel , src ]));
const types = { '.js' : 'text/javascript' , '.wasm' : 'application/wasm' };
server . middlewares . use (( req , res , next ) => {
const url = ( req . url ?? '' ). split ( '?' )[ 0 ];
if ( ! url . startsWith ( DEV_PREFIX )) return next ();
const rel = decodeURIComponent ( url . slice ( DEV_PREFIX . length ));
let file = files . get ( rel );
if ( ! file && rel . startsWith ( ` ${ dtln [ 1 ] } /` )) {
const candidate = path . resolve ( dtln [ 0 ], rel . slice ( dtln [ 1 ]. length + 1 ));
if ( candidate . startsWith ( dtln [ 0 ] + path . sep )) file = candidate ;
}
if ( ! file || ! fs . existsSync ( file )) return next ();
res . setHeader ( 'Content-Type' , types [ path . extname ( file )] ?? 'application/octet-stream' );
fs . createReadStream ( file ). pipe ( res );
return undefined ;
});
},
2026-06-15 20:29:59 -04:00
closeBundle () {
const ecDir = path . resolve ( 'dist/public/element-call' );
if ( ! fs . existsSync ( ecDir )) return ;
const denoiseDir = path . join ( ecDir , 'denoise' );
fs . mkdirSync ( denoiseDir , { recursive : true });
2026-10-18 00:24:03 -04:00
const { sapphi , dtln , dfn } = denoiseSources ();
2026-06-15 20:29:59 -04:00
2026-10-18 00:24:03 -04:00
// A missing source means a partial/changed install would otherwise
// silently ship a broken ML feature (worklet 404 -> raw mic), so we fail
// the build instead.
const missing = sapphi . filter (([ s ]) => ! fs . existsSync ( s )). map (([ s ]) => s );
2026-06-16 01:40:37 -04:00
if ( missing . length > 0 ) {
throw new Error (
`[lotus-denoise] Required denoise asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${ missing . join ( '\n ' ) } ` ,
);
}
2026-10-18 00:24:03 -04:00
sapphi . forEach (([ s , rel ]) => fs . copyFileSync ( s , path . join ( denoiseDir , rel )));
2026-06-15 20:29:59 -04:00
2026-10-18 00:24:03 -04:00
const dtlnEntry = path . join ( dtln [ 0 ], 'audio-worklet.js' );
2026-06-16 17:11:45 -04:00
if ( ! fs . existsSync ( dtlnEntry )) {
throw new Error (
`[lotus-denoise] DTLN entry missing ( ${ dtlnEntry } ) — build aborted. ` +
'Run `npm ci` to install @workadventure/noise-suppression.' ,
);
}
2026-10-18 00:24:03 -04:00
fs . cpSync ( dtln [ 0 ], path . join ( denoiseDir , dtln [ 1 ]), { recursive : true });
2026-06-16 17:11:45 -04:00
2026-10-18 00:24:03 -04:00
const dfnMissing = dfn . filter (([ s ]) => ! fs . existsSync ( s )). map (([ s ]) => s );
2026-06-17 19:57:08 -04:00
if ( dfnMissing . length > 0 ) {
throw new Error (
2026-10-18 00:24:03 -04:00
`[lotus-denoise] DeepFilterNet 3 asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${ dfnMissing . join ( '\n ' ) } \n(Run \`npm ci\`; the vendored wasm/model live under build/denoise-vendor/deepfilternet/.)` ,
2026-06-17 19:57:08 -04:00
);
}
2026-10-18 00:24:03 -04:00
dfn . forEach (([ s , rel ]) => {
const d = path . join ( denoiseDir , rel );
2026-06-17 19:57:08 -04:00
fs . mkdirSync ( path . dirname ( d ), { recursive : true });
fs . copyFileSync ( s , d );
});
2026-06-30 01:33:52 -04:00
// [lotus] DENOISE CUTOVER: the getUserMedia shim is no longer injected.
// Our forked Element Call now runs ML denoise in-source as a real LiveKit
// audio TrackProcessor (activated by lotusDenoiseSource=1 in CallEmbed),
// which survives reconnects (fixes A7). We still copy the denoise/ assets
// above because the in-source processor loads its worklets/wasm from
// ./denoise/ at runtime. To roll back to the shim: restore the
// copy+inject of build/lotus-denoise.js here and swap lotusDenoiseSource
// back to lotusDenoise=ml in CallEmbed.getWidget.
2026-06-15 20:29:59 -04:00
},
};
}
2025-05-18 10:53:56 +05:30
function serverMatrixSdkCryptoWasm ( wasmFilePath ) {
return {
name : 'vite-plugin-serve-matrix-sdk-crypto-wasm' ,
configureServer ( server ) {
server . middlewares . use (( req , res , next ) => {
if ( req . url === wasmFilePath ) {
2026-03-07 18:03:32 +11:00
const resolvedPath = path . join (
path . resolve (),
2026-05-21 23:30:50 -04:00
'/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm' ,
2026-03-07 18:03:32 +11:00
);
2025-05-18 10:53:56 +05:30
if ( fs . existsSync ( resolvedPath )) {
res . setHeader ( 'Content-Type' , 'application/wasm' );
res . setHeader ( 'Cache-Control' , 'no-cache' );
const fileStream = fs . createReadStream ( resolvedPath );
fileStream . pipe ( res );
} else {
res . writeHead ( 404 );
res . end ( 'File not found' );
}
} else {
next ();
}
});
},
};
}
2026-05-22 19:52:23 -04:00
const vendorChunks = ( id ) => {
if ( id . includes ( 'node_modules/matrix-js-sdk' )) return 'matrix-sdk' ;
if ( id . includes ( 'node_modules/react-dom' )) return 'react-dom' ;
if (
id . includes ( 'node_modules/react-router-dom' ) ||
id . includes ( 'node_modules/@remix-run' ) ||
id . includes ( 'node_modules/react-router/' )
)
return 'router' ;
if ( id . includes ( 'node_modules/@tanstack' )) return 'react-query' ;
if ( id . includes ( 'node_modules/linkify' )) return 'linkify' ;
if ( id . includes ( 'node_modules/dompurify' )) return 'dompurify' ;
if ( id . includes ( 'node_modules/@sentry' )) return 'sentry' ;
if ( id . includes ( 'node_modules/i18next' ) || id . includes ( 'node_modules/react-i18next' ))
return 'i18n' ;
if ( id . includes ( 'node_modules/jotai' )) return 'jotai' ;
if ( id . includes ( 'node_modules/immer' )) return 'immer' ;
if ( id . includes ( 'node_modules/folds' )) return 'folds' ;
if ( id . includes ( 'node_modules/emojibase' )) return 'emojibase' ;
2026-10-19 00:00:19 -04:00
// HTML sanitising (sanitize-html pulls in postcss + htmlparser2) and colour
// maths are needed at startup but are big, stable libraries: keep them in
// their own cacheable chunks instead of wherever the chunk graph drops them
// (once Settings became lazy they landed in the entry chunk).
if (
id . includes ( 'node_modules/sanitize-html' ) ||
id . includes ( 'node_modules/postcss' ) ||
id . includes ( 'node_modules/htmlparser2' ) ||
id . includes ( 'node_modules/domhandler' ) ||
id . includes ( 'node_modules/domutils' ) ||
id . includes ( 'node_modules/dom-serializer' ) ||
id . includes ( 'node_modules/entities' ) ||
id . includes ( 'node_modules/parse-srcset' ) ||
id . includes ( 'node_modules/nanoid' ) ||
id . includes ( 'node_modules/picocolors' ) ||
id . includes ( 'node_modules/source-map-js' )
)
return 'sanitize-html' ;
if ( id . includes ( 'node_modules/chroma-js' )) return 'chroma' ;
2026-05-22 19:52:23 -04:00
};
2022-12-20 20:47:51 +05:30
export default defineConfig ({
appType : 'spa' ,
publicDir : false ,
2024-01-21 23:50:56 +11:00
base : buildConfig . base ,
2022-12-20 20:47:51 +05:30
server : {
port : 8080 ,
host : true ,
2025-05-18 10:53:56 +05:30
fs : {
allow : [ '..' ],
},
2022-12-20 20:47:51 +05:30
},
plugins : [
2025-05-18 10:53:56 +05:30
serverMatrixSdkCryptoWasm ( '/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm' ),
2022-12-20 20:47:51 +05:30
viteStaticCopy ( copyFiles ),
2023-06-12 21:15:23 +10:00
vanillaExtractPlugin (),
2022-12-20 20:47:51 +05:30
wasm (),
react (),
2026-05-21 21:45:30 -04:00
copyPdfWorker (),
2026-06-15 20:29:59 -04:00
lotusDenoise (),
2024-09-07 21:45:55 +08:00
VitePWA ({
srcDir : 'src' ,
filename : 'sw.ts' ,
strategies : 'injectManifest' ,
injectRegister : false ,
manifest : false ,
injectManifest : {
2026-07-02 00:19:16 -04:00
// PRECACHE (P5): emit `self.__WB_MANIFEST` into src/sw.ts so it can
// precacheAndRoute the hashed build assets. index.html is deliberately
// EXCLUDED from the manifest (globs only `assets/**`) so navigations
// stay network-first and a new deploy is picked up immediately — see
// the deploy-safety invariants documented in src/sw.ts.
injectionPoint : 'self.__WB_MANIFEST' ,
globPatterns : [ 'assets/**/*.{js,css,wasm}' ],
// Assets are content-hashed, so the filename is the cache key — don't
// append a revision cache-busting param.
dontCacheBustURLsMatching : /assets\// ,
// Raised above the 2 MB default so the ~5.5 MB matrix-sdk crypto wasm
// (hash-busted and hot on every session) is precached deliberately.
maximumFileSizeToCacheInBytes : 6 * 1024 * 1024 ,
2026-05-22 17:17:26 -04:00
// codeSplitting: false is not yet supported by vite-plugin-pwa 1.3.0;
// the inlineDynamicImports deprecation warning from Vite is from pwa internal build
2024-09-07 21:45:55 +08:00
},
2024-09-09 18:45:20 +10:00
devOptions : {
enabled : true ,
2026-03-07 18:03:32 +11:00
type : 'module' ,
},
2024-09-07 21:45:55 +08:00
}),
2022-12-20 20:47:51 +05:30
],
2023-01-30 15:20:53 +11:00
optimizeDeps : {
2026-05-22 12:54:39 -04:00
rolldownOptions : {
2024-05-12 14:06:53 +10:00
define : {
global : 'globalThis' ,
},
},
2023-01-30 15:20:53 +11:00
},
2022-12-20 20:47:51 +05:30
build : {
2026-05-21 21:07:12 -04:00
target : 'esnext' ,
2022-12-20 20:47:51 +05:30
outDir : 'dist' ,
2026-06-28 22:21:09 -04:00
sourcemap : false ,
2022-12-20 20:47:51 +05:30
copyPublicDir : false ,
2026-05-22 19:52:23 -04:00
// manualChunks must be in rolldownOptions (not rollupOptions) for Vite 8 / Rolldown
rolldownOptions : {
checks : { preferBuiltinFeature : false },
perf: lazy-load modal renderers and auth pages, split vendor chunks\n\nMain bundle: 3866 kB -> 2547 kB gzip (637 kB, was 997 kB)\nNew cacheable chunks: matrix-sdk, react-dom, router, react-query, linkify\nLazy-loaded: RoomSettings, SpaceSettings, Search, CreateRoom, CreateSpace, Auth\n\nCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:32:40 -04:00
output : {
2026-05-22 19:52:23 -04:00
manualChunks : vendorChunks ,
perf: lazy-load modal renderers and auth pages, split vendor chunks\n\nMain bundle: 3866 kB -> 2547 kB gzip (637 kB, was 997 kB)\nNew cacheable chunks: matrix-sdk, react-dom, router, react-query, linkify\nLazy-loaded: RoomSettings, SpaceSettings, Search, CreateRoom, CreateSpace, Auth\n\nCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 13:32:40 -04:00
},
2024-05-12 14:06:53 +10:00
},
2026-05-22 19:52:23 -04:00
rollupOptions : {
plugins : [ inject ({ Buffer : [ 'buffer' , 'Buffer' ] })],
},
2022-12-20 20:47:51 +05:30
},
});