import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { readdirSync, readFileSync } from 'fs';
import { createHash } from 'crypto';
import { execFileSync } from 'child_process';
import { VitePWA } from 'vite-plugin-pwa';
const { version: packageVersion } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
const appVersion = `${process.env.VITE_APP_VERSION || packageVersion}`.trim() || packageVersion;
process.env.VITE_APP_VERSION = appVersion;
const releaseTag = `v${appVersion.replace(/^v/i, '').split('-')[0]}`;
const publicBase = process.env.PUBLIC_URL || '/';
const buildOutDir = 'build';
const basePathPrefix = (() => {
const pathname = new URL(publicBase, 'https://example.invalid/').pathname;
return pathname === '/' ? '' : pathname.replace(/^\/+|\/+$/g, '');
})();
const devServerHmr = (() => {
if (!process.env.PORTLESS_URL) {
return { overlay: false };
}
try {
const url = new URL(process.env.PORTLESS_URL);
const isHttps = url.protocol === 'https:';
return {
overlay: false,
protocol: isHttps ? 'wss' : 'ws',
host: url.hostname,
clientPort: Number(url.port || (isHttps ? 443 : 80)),
};
} catch {
return { overlay: false };
}
})();
const neverPrecacheUrls = new Set(['index.html', 'version.json']);
const vitePwaManagedAssetUrls = new Set([
'manifest.webmanifest',
'favicon.ico',
'favicon-404.ico',
'favicon2.ico',
'robots.txt',
'apple-touch-icon.png',
'manifest-icon-192x192.png',
'manifest-icon-512x512.png',
]);
const baselineAppShellUrls = new Set([
'registerSW.js',
'manifest.json',
'manifest.webmanifest',
'favicon.ico',
'favicon-404.ico',
'favicon2.ico',
'robots.txt',
'apple-touch-icon.png',
'manifest-icon-192x192.png',
'manifest-icon-512x512.png',
]);
const ruffleRuntimeDirectory = new URL('./node_modules/@ruffle-rs/ruffle/', import.meta.url);
const ruffleRuntimeFilePattern = /^(?:ruffle\.js|core\.ruffle\.[\da-f]+\.js|[\da-f]+\.wasm)$/;
const mathjaxFontDirectory = new URL('./node_modules/mathjax-full/es5/output/chtml/fonts/woff-v2/', import.meta.url);
const mathjaxFontFilePattern = /^MathJax_[\w-]+\.woff$/;
function readGitRef(args) {
try {
return execFileSync('git', args, {
cwd: new URL('.', import.meta.url),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
}).trim();
} catch {
return '';
}
}
function firstNonEmpty(...values) {
return values.map((value) => `${value || ''}`.trim()).find(Boolean) || '';
}
function normalizeCommitRef(ref) {
return `${ref || ''}`.trim().toLowerCase();
}
function isSameCommitRef(left, right) {
const normalizedLeft = normalizeCommitRef(left);
const normalizedRight = normalizeCommitRef(right);
if (!normalizedLeft || !normalizedRight) {
return false;
}
return normalizedLeft === normalizedRight || normalizedLeft.startsWith(normalizedRight) || normalizedRight.startsWith(normalizedLeft);
}
function readRemoteTagCommitRef(tagName) {
const tagRef = `refs/tags/${tagName}`;
const output = readGitRef(['ls-remote', '--tags', 'origin', tagRef, `${tagRef}^{}`]);
const lines = output
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const releaseLine = lines.find((line) => line.endsWith(`${tagRef}^{}`)) || lines.find((line) => line.endsWith(tagRef)) || '';
return releaseLine.split(/\s+/)[0] || '';
}
function resolveBuildCommitRef() {
return firstNonEmpty(process.env.VITE_COMMIT_REF, process.env.VERCEL_GIT_COMMIT_SHA, process.env.GITHUB_SHA, process.env.COMMIT_REF, readGitRef(['rev-parse', 'HEAD']));
}
function resolveReleaseCommitRef() {
const configuredReleaseCommitRef = firstNonEmpty(process.env.VITE_LATEST_RELEASE_COMMIT_REF, process.env.LATEST_RELEASE_COMMIT_REF);
if (configuredReleaseCommitRef) {
return configuredReleaseCommitRef;
}
if (process.env.GITHUB_REF_NAME === releaseTag) {
return resolveBuildCommitRef();
}
const localReleaseCommitRef = readGitRef(['rev-list', '-n', '1', releaseTag]);
return localReleaseCommitRef || readRemoteTagCommitRef(releaseTag);
}
const buildCommitRef = resolveBuildCommitRef();
const releaseCommitRef = resolveReleaseCommitRef();
const displayCommitRef = buildCommitRef && !isSameCommitRef(buildCommitRef, releaseCommitRef) ? buildCommitRef : '';
function normalizePrecacheUrl(url) {
const normalizedUrl = url.split('?')[0].replace(/^[./]+/, '');
if (basePathPrefix && normalizedUrl.startsWith(`${basePathPrefix}/`)) {
return normalizedUrl.slice(basePathPrefix.length + 1);
}
return normalizedUrl;
}
function collectIndexAssetUrls() {
const indexHtml = readFileSync(new URL(`./${buildOutDir}/index.html`, import.meta.url), 'utf8');
const urls = new Set(baselineAppShellUrls);
const assetAttributePattern = /\b(?:href|src)=["'](?:\.\/|\/)?([^"']+\.(?:css|ico|js|json|png|webmanifest))(?:\?[^"']*)?["']/g;
for (const match of indexHtml.matchAll(assetAttributePattern)) {
urls.add(normalizePrecacheUrl(match[1]));
}
return urls;
}
function keepAppShellPrecacheOnly(manifestEntries) {
const appShellUrls = collectIndexAssetUrls();
const manifest = manifestEntries.filter((entry) => {
const normalizedUrl = normalizePrecacheUrl(entry.url);
if (neverPrecacheUrls.has(normalizedUrl)) {
return false;
}
if (vitePwaManagedAssetUrls.has(normalizedUrl)) {
return false;
}
return appShellUrls.has(normalizedUrl);
});
return { manifest };
}
function appVersionMetadataPlugin() {
const payload = `${JSON.stringify({
version: appVersion,
commitRef: buildCommitRef || undefined,
releaseCommitRef: releaseCommitRef || undefined,
})}\n`;
return {
name: 'fivechan-version-metadata',
configureServer(server) {
server.middlewares.use('/version.json', (_req, res) => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.end(payload);
});
},
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'version.json',
source: payload,
});
},
};
}
function getRuffleRuntimeFileNames() {
return readdirSync(ruffleRuntimeDirectory).filter((fileName) => ruffleRuntimeFilePattern.test(fileName));
}
function getRuffleRuntimeContentType(fileName) {
return fileName.endsWith('.wasm') ? 'application/wasm' : 'application/javascript; charset=utf-8';
}
function ruffleRuntimeAssetsPlugin() {
return {
name: 'fivechan-ruffle-runtime-assets',
configureServer(server) {
server.middlewares.use('/ruffle/', (req, res, next) => {
const fileName = new URL(req.url || '', 'https://example.invalid/').pathname.split('/').pop() || '';
if (!ruffleRuntimeFilePattern.test(fileName)) {
next();
return;
}
try {
const source = readFileSync(new URL(fileName, ruffleRuntimeDirectory));
res.setHeader('Content-Type', getRuffleRuntimeContentType(fileName));
res.setHeader('Cache-Control', 'no-cache');
res.end(source);
} catch {
next();
}
});
},
generateBundle() {
for (const fileName of getRuffleRuntimeFileNames()) {
this.emitFile({
type: 'asset',
fileName: `ruffle/${fileName}`,
source: readFileSync(new URL(fileName, ruffleRuntimeDirectory)),
});
}
},
};
}
// MathJax's CHTML output loads its TeX web fonts from /mathjax/woff-v2/ (see
// src/lib/mathjax/mathjax-config.ts). Like the Ruffle runtime, the fonts are served straight
// from node_modules in dev and emitted into the build output, so nothing is committed to git.
function getMathjaxFontFileNames() {
return readdirSync(mathjaxFontDirectory).filter((fileName) => mathjaxFontFilePattern.test(fileName));
}
function mathjaxFontAssetsPlugin() {
return {
name: 'fivechan-mathjax-font-assets',
configureServer(server) {
server.middlewares.use('/mathjax/woff-v2/', (req, res, next) => {
const fileName = new URL(req.url || '', 'https://example.invalid/').pathname.split('/').pop() || '';
if (!mathjaxFontFilePattern.test(fileName)) {
next();
return;
}
try {
const source = readFileSync(new URL(fileName, mathjaxFontDirectory));
res.setHeader('Content-Type', 'font/woff');
res.setHeader('Cache-Control', 'no-cache');
res.end(source);
} catch {
next();
}
});
},
generateBundle() {
for (const fileName of getMathjaxFontFileNames()) {
this.emitFile({
type: 'asset',
fileName: `mathjax/woff-v2/${fileName}`,
source: readFileSync(new URL(fileName, mathjaxFontDirectory)),
});
}
},
};
}
function getVercelContentSecurityPolicy() {
const vercelConfig = JSON.parse(readFileSync(new URL('./vercel.json', import.meta.url), 'utf8'));
const cspHeader = vercelConfig.headers
?.flatMap((entry) => entry.headers || [])
.find((header) => typeof header.key === 'string' && header.key.toLowerCase() === 'content-security-policy');
if (typeof cspHeader?.value !== 'string') {
throw new Error('vercel.json is missing a Content-Security-Policy header.');
}
return cspHeader.value;
}
function getInlineScriptHashes(indexHtml) {
const scriptPattern = /