Migrate from webpack to vite (#37002)
Replace webpack with Vite 8 as the frontend bundler. Frontend build is around 3-4 times faster than before. Will work on all platforms including riscv64 (via wasm). `iife.js` is a classic render-blocking script in `<head>` (handles web components/early DOM setup). `index.js` is loaded as a `type="module"` script in the footer. All other JS chunks are also module scripts (supported in all browsers since 2018). Entry filenames are content-hashed (e.g. `index.C6Z2MRVQ.js`) and resolved at runtime via the Vite manifest, eliminating the `?v=` cache busting (which was unreliable in some scenarios like vscode dev build). Replaces: https://github.com/go-gitea/gitea/pull/36896 Fixes: https://github.com/go-gitea/gitea/issues/17793 Signed-off-by: silverwind <me@silverwind.io> Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Claude (Opus 4.6) <noreply@anthropic.com> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
332
vite.config.ts
Normal file
332
vite.config.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import {build, defineConfig} from 'vite';
|
||||
import vuePlugin from '@vitejs/plugin-vue';
|
||||
import {stringPlugin} from 'vite-string-plugin';
|
||||
import {readFileSync, writeFileSync, unlinkSync, globSync} from 'node:fs';
|
||||
import {join, parse} from 'node:path';
|
||||
import {env} from 'node:process';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import tailwindConfig from './tailwind.config.ts';
|
||||
import wrapAnsi from 'wrap-ansi';
|
||||
import licensePlugin from 'rollup-plugin-license';
|
||||
import type {InlineConfig, Plugin, Rolldown} from 'vite';
|
||||
|
||||
const isProduction = env.NODE_ENV !== 'development';
|
||||
|
||||
// ENABLE_SOURCEMAP accepts the following values:
|
||||
// true - all sourcemaps enabled, the default in development
|
||||
// reduced - sourcemaps only for index.js, the default in production
|
||||
// false - all sourcemaps disabled
|
||||
let enableSourcemap: string;
|
||||
if ('ENABLE_SOURCEMAP' in env) {
|
||||
enableSourcemap = ['true', 'false'].includes(env.ENABLE_SOURCEMAP!) ? env.ENABLE_SOURCEMAP! : 'reduced';
|
||||
} else {
|
||||
enableSourcemap = isProduction ? 'reduced' : 'true';
|
||||
}
|
||||
const outDir = join(import.meta.dirname, 'public/assets');
|
||||
|
||||
const themes: Record<string, string> = {};
|
||||
for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirname})) {
|
||||
themes[parse(path).name] = join(import.meta.dirname, path);
|
||||
}
|
||||
|
||||
const webComponents = new Set([
|
||||
// our own, in web_src/js/webcomponents
|
||||
'overflow-menu',
|
||||
'origin-url',
|
||||
'relative-time',
|
||||
// from dependencies
|
||||
'markdown-toolbar',
|
||||
'text-expander',
|
||||
]);
|
||||
|
||||
function formatLicenseText(licenseText: string) {
|
||||
return wrapAnsi(licenseText || '', 80).trim();
|
||||
}
|
||||
|
||||
const commonRolldownOptions: Rolldown.RolldownOptions = {
|
||||
checks: {
|
||||
eval: false, // htmx needs eval
|
||||
pluginTimings: false,
|
||||
},
|
||||
};
|
||||
|
||||
function commonViteOpts({build, ...other}: InlineConfig): InlineConfig {
|
||||
const {rolldownOptions, ...otherBuild} = build || {};
|
||||
return {
|
||||
base: './', // make all asset URLs relative, so it works in subdirectory deployments
|
||||
configFile: false,
|
||||
root: import.meta.dirname,
|
||||
publicDir: false,
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: false,
|
||||
sourcemap: enableSourcemap !== 'false',
|
||||
target: 'es2020',
|
||||
minify: isProduction ? 'oxc' : false,
|
||||
cssMinify: isProduction ? 'esbuild' : false,
|
||||
chunkSizeWarningLimit: Infinity,
|
||||
assetsInlineLimit: 32768,
|
||||
reportCompressedSize: false,
|
||||
rolldownOptions: {
|
||||
...commonRolldownOptions,
|
||||
...rolldownOptions,
|
||||
},
|
||||
...otherBuild,
|
||||
},
|
||||
...other,
|
||||
};
|
||||
}
|
||||
|
||||
const iifeEntry = join(import.meta.dirname, 'web_src/js/iife.ts');
|
||||
|
||||
function iifeBuildOpts({entryFileNames, write}: {entryFileNames: string, write?: boolean}) {
|
||||
return commonViteOpts({
|
||||
build: {
|
||||
lib: {entry: iifeEntry, formats: ['iife'], name: 'iife'},
|
||||
rolldownOptions: {output: {entryFileNames}},
|
||||
...(write === false && {write: false}),
|
||||
},
|
||||
plugins: [stringPlugin()],
|
||||
});
|
||||
}
|
||||
|
||||
// Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory
|
||||
// and rebuilds on file changes. In prod mode, writes to disk during closeBundle.
|
||||
function iifePlugin(): Plugin {
|
||||
let iifeCode = '';
|
||||
let iifeMap = '';
|
||||
const iifeModules = new Set<string>();
|
||||
let isBuilding = false;
|
||||
return {
|
||||
name: 'iife',
|
||||
async configureServer(server) {
|
||||
const buildAndCache = async () => {
|
||||
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.js', write: false}));
|
||||
const output = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
|
||||
const chunk = output.output[0];
|
||||
iifeCode = chunk.code.replace(/\/\/# sourceMappingURL=.*/, '//# sourceMappingURL=__vite_iife.js.map');
|
||||
const mapAsset = output.output.find((o) => o.fileName.endsWith('.map'));
|
||||
iifeMap = mapAsset && 'source' in mapAsset ? String(mapAsset.source) : '';
|
||||
iifeModules.clear();
|
||||
for (const id of Object.keys(chunk.modules)) iifeModules.add(id);
|
||||
};
|
||||
await buildAndCache();
|
||||
|
||||
let needsRebuild = false;
|
||||
server.watcher.on('change', async (path) => {
|
||||
if (!iifeModules.has(path)) return;
|
||||
needsRebuild = true;
|
||||
if (isBuilding) return;
|
||||
isBuilding = true;
|
||||
try {
|
||||
do {
|
||||
needsRebuild = false;
|
||||
await buildAndCache();
|
||||
} while (needsRebuild);
|
||||
server.ws.send({type: 'full-reload'});
|
||||
} finally {
|
||||
isBuilding = false;
|
||||
}
|
||||
});
|
||||
|
||||
server.middlewares.use((req, res, next) => {
|
||||
// "__vite_iife" is a virtual file in memory, serve it directly
|
||||
const pathname = req.url!.split('?')[0];
|
||||
if (pathname === '/web_src/js/__vite_iife.js') {
|
||||
res.setHeader('Content-Type', 'application/javascript');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(iifeCode);
|
||||
} else if (pathname === '/web_src/js/__vite_iife.js.map') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(iifeMap);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
async closeBundle() {
|
||||
for (const file of globSync('js/iife.*.js*', {cwd: outDir})) unlinkSync(join(outDir, file));
|
||||
const result = await build(iifeBuildOpts({entryFileNames: 'js/iife.[hash:8].js'}));
|
||||
const buildOutput = (Array.isArray(result) ? result[0] : result) as Rolldown.RolldownOutput;
|
||||
const entry = buildOutput.output.find((o) => o.fileName.startsWith('js/iife.'));
|
||||
if (!entry) throw new Error('IIFE build produced no output');
|
||||
const manifestPath = join(outDir, '.vite', 'manifest.json');
|
||||
writeFileSync(manifestPath, JSON.stringify({
|
||||
...JSON.parse(readFileSync(manifestPath, 'utf8')),
|
||||
'web_src/js/iife.ts': {file: entry.fileName, name: 'iife', isEntry: true},
|
||||
}, null, 2));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// In reduced sourcemap mode, only keep sourcemaps for main files
|
||||
function reducedSourcemapPlugin(): Plugin {
|
||||
return {
|
||||
name: 'reduced-sourcemap',
|
||||
apply: 'build',
|
||||
closeBundle() {
|
||||
if (enableSourcemap !== 'reduced') return;
|
||||
for (const file of globSync('{js,css}/*.map', {cwd: outDir})) {
|
||||
if (!file.startsWith('js/index.') && !file.startsWith('js/iife.')) unlinkSync(join(outDir, file));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Filter out legacy font formats from CSS, keeping only woff2
|
||||
function filterCssUrlPlugin(): Plugin {
|
||||
return {
|
||||
name: 'filter-css-url',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
if (!id.endsWith('.css') || !id.includes('katex')) return null;
|
||||
return code.replace(/,\s*url\([^)]*\.(?:woff|ttf)\)\s*format\("[^"]*"\)/gi, '');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const viteDevServerPort = Number(env.FRONTEND_DEV_SERVER_PORT) || 3001;
|
||||
const viteDevPortFilePath = join(outDir, '.vite', 'dev-port');
|
||||
|
||||
// Write the Vite dev server's actual port to a file so the Go server can discover it for proxying.
|
||||
function viteDevServerPortPlugin(): Plugin {
|
||||
return {
|
||||
name: 'vite-dev-server-port',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.httpServer!.once('listening', () => {
|
||||
const addr = server.httpServer!.address();
|
||||
if (typeof addr === 'object' && addr) {
|
||||
writeFileSync(viteDevPortFilePath, String(addr.port));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(commonViteOpts({
|
||||
appType: 'custom', // Go serves all HTML, disable Vite's HTML handling
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: viteDevServerPort,
|
||||
open: false,
|
||||
host: '0.0.0.0',
|
||||
strictPort: false,
|
||||
fs: {
|
||||
// VITE-DEV-SERVER-SECURITY: the dev server will be exposed to public by Gitea's web server, so we need to strictly limit the access
|
||||
// Otherwise `/@fs/*` will be able to access any file (including app.ini which contains INTERNAL_TOKEN)
|
||||
strict: true,
|
||||
allow: [
|
||||
'assets',
|
||||
'node_modules',
|
||||
'public',
|
||||
'web_src',
|
||||
// do not add any other directories here, unless you are absolutely sure it's safe to expose them to the public
|
||||
],
|
||||
},
|
||||
headers: {
|
||||
'Cache-Control': 'no-store', // prevent browser disk cache
|
||||
},
|
||||
warmup: {
|
||||
clientFiles: [
|
||||
// warmup the important entry points
|
||||
'web_src/js/index.ts',
|
||||
'web_src/css/index.css',
|
||||
'web_src/css/themes/*.css',
|
||||
],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
modulePreload: false,
|
||||
manifest: true,
|
||||
rolldownOptions: {
|
||||
input: {
|
||||
index: join(import.meta.dirname, 'web_src/js/index.ts'),
|
||||
swagger: join(import.meta.dirname, 'web_src/js/standalone/swagger.ts'),
|
||||
'external-render-iframe': join(import.meta.dirname, 'web_src/js/standalone/external-render-iframe.ts'),
|
||||
'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/features/eventsource.sharedworker.ts'),
|
||||
...(!isProduction && {
|
||||
devtest: join(import.meta.dirname, 'web_src/js/standalone/devtest.ts'),
|
||||
}),
|
||||
...themes,
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'js/[name].[hash:8].js',
|
||||
chunkFileNames: 'js/[name].[hash:8].js',
|
||||
assetFileNames: ({names}) => {
|
||||
const name = names[0];
|
||||
if (name.endsWith('.css')) return 'css/[name].[hash:8].css';
|
||||
if (/\.(ttf|woff2?)$/.test(name)) return 'fonts/[name].[hash:8].[ext]';
|
||||
return '[name].[hash:8].[ext]';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
rolldownOptions: {
|
||||
...commonRolldownOptions,
|
||||
output: {
|
||||
entryFileNames: 'js/[name].[hash:8].js',
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
transformer: 'postcss',
|
||||
postcss: {
|
||||
plugins: [
|
||||
tailwindcss(tailwindConfig),
|
||||
],
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: true,
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
|
||||
},
|
||||
plugins: [
|
||||
iifePlugin(),
|
||||
viteDevServerPortPlugin(),
|
||||
reducedSourcemapPlugin(),
|
||||
filterCssUrlPlugin(),
|
||||
stringPlugin(),
|
||||
vuePlugin({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag) => webComponents.has(tag),
|
||||
},
|
||||
},
|
||||
}),
|
||||
isProduction ? licensePlugin({
|
||||
thirdParty: {
|
||||
output: {
|
||||
file: join(import.meta.dirname, 'public/assets/licenses.txt'),
|
||||
template(deps) {
|
||||
const line = '-'.repeat(80);
|
||||
const goJson = readFileSync(join(import.meta.dirname, 'assets/go-licenses.json'), 'utf8');
|
||||
const goModules = JSON.parse(goJson).map(({name, licenseText}: {name: string, licenseText: string}) => {
|
||||
return {name, body: formatLicenseText(licenseText)};
|
||||
});
|
||||
const jsModules = deps.map((dep) => {
|
||||
return {name: dep.name, version: dep.version, body: formatLicenseText(dep.licenseText ?? '')};
|
||||
});
|
||||
const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return modules.map(({name, version, body}: {name: string, version?: string, body: string}) => {
|
||||
const title = version ? `${name}@${version}` : name;
|
||||
return `${line}\n${title}\n${line}\n${body}`;
|
||||
}).join('\n');
|
||||
},
|
||||
},
|
||||
allow(dependency) {
|
||||
if (dependency.name === 'khroma') return true; // MIT: https://github.com/fabiospampinato/khroma/pull/33
|
||||
return /(Apache-2\.0|0BSD|BSD-2-Clause|BSD-3-Clause|MIT|ISC|CPAL-1\.0|Unlicense|EPL-1\.0|EPL-2\.0)/.test(dependency.license ?? '');
|
||||
},
|
||||
},
|
||||
}) : {
|
||||
name: 'dev-licenses-stub',
|
||||
closeBundle() {
|
||||
writeFileSync(join(outDir, 'licenses.txt'), 'Licenses are disabled during development');
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
Reference in New Issue
Block a user