// Vite configuration of the SvelteKit site. The library tests run with // vitest.config.ts, which vitest prefers when both files exist. import { sveltekit } from '@sveltejs/kit/vite'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { defineConfig, type Plugin } from 'vite'; // Records what the client bundle is made of, for scripts/check-build.mjs: // each chunk with the chunks it imports and the modules it contains, with // their rendered sizes. The record goes to .svelte-kit/output, outside the // site. function clientModules(): Plugin { let root = process.cwd(); return { name: 'datekeys:client-modules', apply: 'build', configResolved(config) { root = config.root; }, generateBundle(_, bundle) { if (this.environment.name !== 'client') return; const chunks: Record }> = {}; for (const out of Object.values(bundle)) { if (out.type !== 'chunk') continue; chunks[out.fileName] = { imports: out.imports, dynamicImports: out.dynamicImports, modules: Object.fromEntries(Object.entries(out.modules).map(([id, m]) => [id.replace(/\\/g, '/'), m.renderedLength])), }; } const dir = join(root, '.svelte-kit', 'output'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'client-modules.json'), JSON.stringify({ chunks }, null, 1)); }, }; } // The package directory of a module id under node_modules, or undefined. function packageDir(id: string): string | undefined { const i = id.lastIndexOf('/node_modules/'); if (i < 0) return undefined; const parts = id.slice(i + '/node_modules/'.length).split('/'); const name = parts[0]!.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]!; return `${id.slice(0, i)}/node_modules/${name}`; } // The package of a virtual module of the bundler, such as \0vite/preload-helper.js // or \0rolldown/runtime.js, or undefined for any other. function virtualPackageDir(id: string, root: string): string | undefined { const name = /^\0(vite|rolldown)\//.exec(id)?.[1]; return name === undefined ? undefined : `${root}/node_modules/${name}`; } /** * The license notice kept in the leading comment of a module derived from * another project, such as src/lib/dkc/ibe.ts: the block of lines indented * three spaces that holds a copyright line, with the paragraph that * introduces it. Undefined when the module has none. */ function derivedNotice(source: string): string | undefined { const header: string[] = []; for (const line of source.split(/\r?\n/)) { if (!line.startsWith('//')) break; header.push(line); } // The block runs from its first indented line to its last, over blank // comment lines; list items of the comment are indented too, so it is // found by its copyright line. const copyright = header.findIndex((l) => /^\/\/ {3}.*copyright/i.test(l)); if (copyright < 0) return undefined; let start = copyright; while (start > 0 && header[start - 1]!.startsWith('// ')) start--; let end = copyright; while (end < header.length && (header[end]!.startsWith('// ') || header[end] === '//')) end++; while (header[end - 1] === '//') end--; const notice = header.slice(start, end).map((l) => (l === '//' ? '' : l.slice(5))); // The paragraph before it, which names the project. let to = start; while (to > 0 && header[to - 1] === '//') to--; let from = to; while (from > 0 && header[from - 1] !== '//') from--; const intro = header.slice(from, to).map((l) => l.replace(/^\/\/ ?/, '')); return [...intro, '', ...notice].join('\n'); } const RULE = '='.repeat(72); const LINE = '-'.repeat(72); // Writes licenses.txt at the root of the site: the notices of the third-party // code in the client bundle, whose minified JavaScript keeps no comments. It // holds the notice of every module of src/ derived from another project // (derivedNotice), the license file of every npm package with a module in // the bundle, and the license of the site itself. scripts/check-build.mjs // checks that nothing is missing. function thirdPartyNotices(): Plugin { let root = process.cwd(); return { name: 'datekeys:third-party-notices', apply: 'build', configResolved(config) { root = config.root.replace(/\\/g, '/'); }, generateBundle(_, bundle) { if (this.environment.name !== 'client') return; const ids = new Set(); const dirs = new Set(); for (const out of Object.values(bundle)) { if (out.type !== 'chunk') continue; for (const [raw, m] of Object.entries(out.modules)) { const id = raw.replace(/\\/g, '/'); ids.add(id); // The bundler's own virtual modules (\0…) are code of its package. const dir = id.startsWith('\0') ? virtualPackageDir(id, root) : packageDir(id); if (dir !== undefined) dirs.add(dir); else if (id.startsWith('\0') && m.renderedLength > 0) this.error(`no license known for the virtual module ${JSON.stringify(id)}`); } } const derived: string[] = []; for (const id of [...ids].sort()) { if (!id.startsWith(`${root}/src/`) || !/\.(ts|js|svelte)$/.test(id)) continue; const notice = derivedNotice(readFileSync(id, 'utf8')); if (notice !== undefined) derived.push(`${id.slice(root.length + 1)}\n\n${notice}`); } const packages: string[] = []; for (const dir of [...dirs].sort()) { const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name: string; version: string; license?: string }; const file = readdirSync(dir).find((f) => /^(licen[cs]e|copying)(\.md|\.txt)?$/i.test(f)); if (file === undefined) this.error(`${pkg.name} ${pkg.version} has no license file`); const nested = dir.slice(0, dir.lastIndexOf('/node_modules/')).includes('/node_modules/'); const where = nested ? `, anidada bajo ${dir.slice(dir.indexOf('/node_modules/') + '/node_modules/'.length, dir.lastIndexOf('/node_modules/'))}` : ''; let license = readFileSync(join(dir, file), 'utf8').trim(); // Vite's file also carries the licenses of its Node-side dependencies, // none of which reaches the client: only its own part applies. const bundled = license.indexOf('\n# Licenses of bundled dependencies'); if (pkg.name === 'vite' && bundled > 0) license = license.slice(0, bundled).trim(); // The code rolldown writes into the bundle derives from Rollup and // esbuild, whose notices it keeps apart. const third = join(dir, 'THIRD-PARTY-LICENSE'); if (pkg.name === 'rolldown' && existsSync(third)) license += `\n\n${readFileSync(third, 'utf8').trim()}`; packages.push(`${LINE}\n${pkg.name} ${pkg.version} (${pkg.license ?? 'sin campo license'}${where})\n${LINE}\n\n${license}`); } // The word lists that /create fetches (src/lib/inspector/create-words.ts) // are not code and have a license of their own, which the README of // wordlists/, copied from datekeys-go, gives with their source. const lists = readFileSync(join(root, 'wordlists', 'README.md'), 'utf8').trim(); const text = [ 'Avisos de licencia de este sitio', '', 'Este sitio es datekeys-ts, con licencia Apache-2.0 (al final de este fichero). Su', 'JavaScript lleva, minimizado, código de terceros: módulos derivados de otros', 'proyectos y paquetes npm, cada uno con su aviso de copyright y su licencia.', '', RULE, 'Módulos de datekeys-ts derivados de otros proyectos', RULE, '', derived.join(`\n\n${LINE}\n\n`), '', RULE, 'Paquetes npm', RULE, '', packages.join('\n\n'), '', RULE, 'Listas de palabras', RULE, '', 'Las palabras al azar de /create salen de las listas de wordlists/, copiadas de', 'datekeys-go. No son código de datekeys-ts ni tienen su licencia: su README, que', 'sigue, dice de dónde sale cada una, cómo se hizo y su licencia.', '', lists, '', RULE, 'datekeys-ts (Apache-2.0)', RULE, '', readFileSync(join(root, 'LICENSE'), 'utf8').trim(), '', ].join('\n'); this.emitFile({ type: 'asset', fileName: 'licenses.txt', source: text }); }, }; } // `vite preview` sends the pages without Cache-Control, so a browser may show // an old page after a new build, whose chunks are gone from build/: the local // preview has the pages revalidated on every load. The hashed files of // _app/immutable keep the year-long cache that SvelteKit gives them. It goes // before the SvelteKit plugin, which serves the prerendered pages itself and // would answer first. A real host should serve the pages the same way. function revalidatePages(): Plugin { return { name: 'datekeys:revalidate-pages', configurePreviewServer(server) { server.middlewares.use((req, res, next) => { if (!req.url?.startsWith('/_app/immutable/')) res.setHeader('Cache-Control', 'no-cache'); next(); }); }, }; } export default defineConfig({ plugins: [revalidatePages(), sveltekit(), clientModules(), thirdPartyNotices()], build: { // Never inline an asset as a data: URL. The CSP allows only the page's // own origin, so the official fixtures (src/lib/inspector/fixtures.ts) // must be separate same-origin files, whatever their size. assetsInlineLimit: 0, }, optimizeDeps: { // Every dependency the pages load on demand, pre-bundled when the dev // server starts. Found only on the first import, as the opening of a // capsule does, the dev server bundles it then and reloads the page, and // the import in flight fails: "Failed to fetch dynamically imported // module". src/lib/dependencies.test.ts checks that the list is complete. include: [ '@noble/ciphers/chacha.js', '@noble/curves/bls12-381.js', '@noble/curves/ed25519.js', '@noble/curves/nist.js', '@noble/curves/utils.js', '@noble/hashes/hkdf.js', '@noble/hashes/legacy.js', '@noble/hashes/sha2.js', 'age-encryption', ], }, server: { fs: { // The dev server serves the official fixtures straight from testdata/, // the single source of truth; the build copies them as hashed assets. allow: ['testdata/fixtures'], }, }, });