diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 90b336333..161c79984 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -58,6 +58,9 @@ jobs: - name: Run TypeScript typecheck run: pnpm typecheck + - name: Verify modern polyfill inventory against automatic discovery + run: pnpm polyfills:check + - name: Run tests run: pnpm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32df1f29e..0b335f37b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,15 @@ For frontend-only development, use `pnpm web:dev`. It does not start the Rust ba ### Build the Project +`pnpm web:build` uses a generated modern-polyfill inventory and rejects changed +emitted module inputs, output, targets, or locked dependencies before writing +output. Inputs include dependency code and code later removed by minification. +When it reports a stale inventory, run `pnpm polyfills:generate` and commit +`scripts/modern-polyfills.json`. Generation preserves automatic discovery's import +order and requires byte-identical automatic/explicit outputs for the macOS, Linux, +and Windows production defines. CI repeats this comparison with +`pnpm polyfills:check`; browser targets and worker configuration stay unchanged. + Standard build: ```bash diff --git a/package.json b/package.json index 12ea76f18..0842654de 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "tauri": "tauri", "web:dev": "vite", "web:build": "tsrs -p . --noEmit && vite build", + "polyfills:generate": "node scripts/generate-modern-polyfills.mjs --write", + "polyfills:check": "node scripts/generate-modern-polyfills.mjs --check", "web:serve": "vite preview", "prebuild": "node scripts/prebuild.mjs", "updater": "node scripts/updater.mjs", diff --git a/scripts/generate-modern-polyfills.mjs b/scripts/generate-modern-polyfills.mjs new file mode 100644 index 000000000..db05b3b0b --- /dev/null +++ b/scripts/generate-modern-polyfills.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { build } from 'vite' + +const [command, ...extra] = process.argv.slice(2) +if (!['--write', '--check'].includes(command) || extra.length) { + throw new Error( + 'Usage: node scripts/generate-modern-polyfills.mjs --write|--check', + ) +} +const inventoryPath = 'scripts/modern-polyfills.json' +const staleMessage = + 'Modern polyfill inventory is stale; run pnpm polyfills:generate and commit scripts/modern-polyfills.json' + +function outputHashes(result) { + assert( + !Array.isArray(result) && 'output' in result, + 'Expected one app output', + ) + return result.output + .map((file) => [ + file.fileName, + createHash('sha256') + .update(file.type === 'chunk' ? file.code : file.source) + .digest('hex'), + ]) + .sort(([a], [b]) => a.localeCompare(b)) +} + +const temporary = mkdtempSync(path.join(os.tmpdir(), 'modern-polyfills-')) +const capture = path.join(temporary, 'capture.json') +const candidate = path.join(temporary, 'inventory.json') +const snapshots = [] +const fingerprints = [] +let imports +try { + process.env.MODERN_POLYFILLS_CAPTURE = capture + // OS_PLATFORM changes emitted code; certify each production define without + // launching a native app or normalizing away code the detector actually sees. + for (const platform of ['darwin', 'linux', 'win32']) { + const config = { + logLevel: 'error', + define: { OS_PLATFORM: JSON.stringify(platform) }, + build: { write: false }, + } + const automatic = await build(config) + const detected = JSON.parse(readFileSync(capture, 'utf8')) + if (imports) assert.deepEqual(detected.imports, imports) + imports = detected.imports + fingerprints.push(detected.fingerprint) + snapshots.push({ config, hashes: outputHashes(automatic) }) + } + const content = `${JSON.stringify({ imports, fingerprints: [...new Set(fingerprints)].sort() }, null, 2)}\n` + writeFileSync(candidate, content) + delete process.env.MODERN_POLYFILLS_CAPTURE + process.env.MODERN_POLYFILLS_INVENTORY = candidate + for (const { config, hashes } of snapshots) { + assert.deepEqual( + outputHashes(await build(config)), + hashes, + 'Explicit polyfills must be byte-identical to automatic discovery', + ) + } + if (command === '--check') { + assert.equal(readFileSync(inventoryPath, 'utf8'), content, staleMessage) + } else { + writeFileSync(inventoryPath, content) + } + console.log( + `Modern polyfills ${command === '--check' ? 'verified' : 'generated'}: ${imports.length} imports; all three OS outputs byte-identical to automatic discovery`, + ) +} finally { + delete process.env.MODERN_POLYFILLS_CAPTURE + delete process.env.MODERN_POLYFILLS_INVENTORY + rmSync(temporary, { recursive: true }) +} diff --git a/scripts/modern-polyfills.json b/scripts/modern-polyfills.json new file mode 100644 index 000000000..c485b3eb7 --- /dev/null +++ b/scripts/modern-polyfills.json @@ -0,0 +1,72 @@ +{ + "imports": [ + "core-js/modules/es.error.cause.js", + "core-js/modules/es.array.at.js", + "core-js/modules/es.array.includes.js", + "core-js/modules/es.array.push.js", + "core-js/modules/es.array.unshift.js", + "core-js/modules/es.array-buffer.detached.js", + "core-js/modules/es.array-buffer.transfer.js", + "core-js/modules/es.array-buffer.transfer-to-fixed-length.js", + "core-js/modules/es.iterator.constructor.js", + "core-js/modules/es.iterator.every.js", + "core-js/modules/es.iterator.filter.js", + "core-js/modules/es.iterator.find.js", + "core-js/modules/es.iterator.flat-map.js", + "core-js/modules/es.iterator.for-each.js", + "core-js/modules/es.iterator.map.js", + "core-js/modules/es.iterator.reduce.js", + "core-js/modules/es.iterator.some.js", + "core-js/modules/es.iterator.to-array.js", + "core-js/modules/es.json.parse.js", + "core-js/modules/es.json.stringify.js", + "core-js/modules/es.map.get-or-insert.js", + "core-js/modules/es.map.get-or-insert-computed.js", + "core-js/modules/es.regexp.flags.js", + "core-js/modules/es.set.difference.v2.js", + "core-js/modules/es.set.intersection.v2.js", + "core-js/modules/es.set.is-disjoint-from.v2.js", + "core-js/modules/es.set.is-subset-of.v2.js", + "core-js/modules/es.set.is-superset-of.v2.js", + "core-js/modules/es.set.symmetric-difference.v2.js", + "core-js/modules/es.set.union.v2.js", + "core-js/modules/es.string.at-alternative.js", + "core-js/modules/es.typed-array.at.js", + "core-js/modules/es.typed-array.fill.js", + "core-js/modules/es.typed-array.find-last.js", + "core-js/modules/es.typed-array.find-last-index.js", + "core-js/modules/es.typed-array.set.js", + "core-js/modules/es.typed-array.sort.js", + "core-js/modules/es.typed-array.to-reversed.js", + "core-js/modules/es.typed-array.to-sorted.js", + "core-js/modules/es.typed-array.with.js", + "core-js/modules/es.uint8-array.set-from-base64.js", + "core-js/modules/es.uint8-array.set-from-hex.js", + "core-js/modules/es.uint8-array.to-base64.js", + "core-js/modules/es.uint8-array.to-hex.js", + "core-js/modules/es.weak-map.get-or-insert.js", + "core-js/modules/es.weak-map.get-or-insert-computed.js", + "core-js/modules/esnext.array.group.js", + "core-js/modules/web.dom-exception.stack.js", + "core-js/modules/web.immediate.js", + "core-js/modules/web.url-search-params.delete.js", + "core-js/modules/web.url-search-params.has.js", + "core-js/modules/web.url-search-params.size.js", + "core-js/modules/es.object.has-own.js", + "core-js/modules/es.array.with.js", + "core-js/modules/es.symbol.async-dispose.js", + "core-js/modules/es.async-iterator.async-dispose.js", + "core-js/modules/es.symbol.dispose.js", + "core-js/modules/es.iterator.dispose.js", + "core-js/modules/es.aggregate-error.cause.js", + "core-js/modules/es.array.find-last.js", + "core-js/modules/es.iterator.drop.js", + "core-js/modules/es.iterator.take.js", + "core-js/modules/web.structured-clone.js" + ], + "fingerprints": [ + "30dd6a7f16e7065fa9803260e11aa2f2db56cabb69664257c940ab05bae8505b", + "5630fe8a486687073b6d9894dd39735b77d5d8177432a4ada81947a429ff6991", + "ddbb980f9ad3fe92ee7c30248d46ed4cdeb421c714266e627b825b59042c30bc" + ] +} diff --git a/scripts/modern-polyfills.mjs b/scripts/modern-polyfills.mjs new file mode 100644 index 000000000..9bf015942 --- /dev/null +++ b/scripts/modern-polyfills.mjs @@ -0,0 +1,139 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +import legacy, { detectPolyfills } from '@vitejs/plugin-legacy' + +const digest = (value) => createHash('sha256').update(value).digest('hex') +const inventoryPath = 'scripts/modern-polyfills.json' +const staleMessage = + 'Modern polyfill inventory is stale; run pnpm polyfills:generate and commit scripts/modern-polyfills.json' + +// Bind complete emitted module inputs as well as output: minification can hide APIs. +// Generation double-checks the exported detector against legacy's automatic build. +export function guardedLegacy(options) { + const capturePath = process.env.MODERN_POLYFILLS_CAPTURE + const inventory = capturePath + ? null + : JSON.parse( + readFileSync( + process.env.MODERN_POLYFILLS_INVENTORY || inventoryPath, + 'utf8', + ), + ) + const plugins = legacy({ + ...options, + modernPolyfills: Boolean(capturePath), + additionalModernPolyfills: [ + ...options.additionalModernPolyfills, + ...(capturePath ? [] : inventory.imports), + ], + }) + const postProcess = plugins.find( + (plugin) => plugin.name === 'vite:legacy-post-process', + ) + if (!postProcess?.renderChunk) { + throw new Error('plugin-legacy must provide its post-process render hook') + } + const original = postProcess.renderChunk + const render = typeof original === 'function' ? original : original.handler + const discovered = new Map() + let chunkNames + let context + if (capturePath) { + postProcess.renderChunk = async function (raw, chunk, output, meta) { + chunkNames ??= Object.keys(meta.chunks) + const imports = new Set() + await detectPolyfills( + raw, + options.modernTargets, + options.assumptions || {}, + imports, + ) + discovered.set(chunk.fileName, imports) + return render.call(this, raw, chunk, output, meta) + } + } + return [ + ...plugins, + { + name: 'guard-modern-polyfills', + enforce: 'post', + apply: 'build', + configResolved(config) { + if (config.build.ssr || options.renderLegacyChunks !== false) { + throw new Error( + 'Modern polyfill guard requires a modern-only app build', + ) + } + // Custom import paths must not bind the inventory to a checkout location. + context = [ + { + ...options, + additionalModernPolyfills: options.additionalModernPolyfills.map( + (file) => + path.relative(process.cwd(), file).split(path.sep).join('/'), + ), + }, + config.build.target, + config.mode, + ...[ + 'package.json', + 'pnpm-lock.yaml', + 'vite.config.mts', + 'scripts/modern-polyfills.mjs', + 'scripts/generate-modern-polyfills.mjs', + ].map((file) => digest(readFileSync(file))), + ...[ + '.browserslistrc', + 'browserslist', + 'src/.browserslistrc', + 'src/browserslist', + ].map((file) => + existsSync(file) ? digest(readFileSync(file)) : null, + ), + digest( + readFileSync(new URL(import.meta.resolve('@vitejs/plugin-legacy'))), + ), + ] + }, + generateBundle(_output, bundle) { + const imports = capturePath + ? [ + ...new Set( + chunkNames.flatMap((name) => [...discovered.get(name)]), + ), + ] + : inventory.imports + const chunks = Object.values(bundle).filter( + (file) => file.type === 'chunk', + ) + const moduleIds = new Set( + chunks.flatMap((chunk) => Object.keys(chunk.modules)), + ) + const inputs = [...moduleIds].map((id) => { + // Rolldown's runtime is generated, not a transformed input module. + if (id === '\0rolldown/runtime.js') return null + // Vite's HTML entry code embeds an absolute import to the main module. + if (id.endsWith('.html')) return digest(readFileSync(id)) + const code = this.getModuleInfo(id)?.code + if (code == null) this.error(`Missing emitted module input: ${id}`) + return digest(code) + }) + const fingerprint = digest( + JSON.stringify([ + context, + inputs.sort(), + chunks.map((chunk) => digest(chunk.code)).sort(), + imports, + ]), + ) + if (capturePath) { + writeFileSync(capturePath, JSON.stringify({ fingerprint, imports })) + } else if (!inventory.fingerprints.includes(fingerprint)) { + this.error(staleMessage) + } + }, + }, + ] +} diff --git a/vite.config.mts b/vite.config.mts index 85a9bbfc1..acd2f1b0f 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -1,10 +1,11 @@ import path from 'node:path' -import legacy from '@vitejs/plugin-legacy' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import svgr from 'vite-plugin-svgr' +import { guardedLegacy } from './scripts/modern-polyfills.mjs' + const svgPlugin = svgr() const svgLoad = svgPlugin.load if (!svgLoad) throw new Error('vite-plugin-svgr must provide a load hook') @@ -20,12 +21,9 @@ export default defineConfig({ plugins: [ svgPlugin, react(), - legacy({ + guardedLegacy({ modernTargets: ['edge>=109', 'safari>=14'], renderLegacyChunks: false, - // Auto-detected from the emitted bundle, dependencies included, - // so post-Safari-14 APIs never depend on extending a manual list. - modernPolyfills: true, additionalModernPolyfills: [ path.resolve('./src/polyfills/matchMedia.js'), path.resolve('./src/polyfills/WeakRef.js'),