diff --git a/package.json b/package.json index e6621bdfbe..df9e6b1d99 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "license:generate": "tsx tools/generateLicenses.mts", "license:merge": "tsx tools/mergeLicenses.mts", + "check-suspicious-imports": "tsx tools/checkSuspiciousImports.mts", "test:unit": "vitest --run", "test-watch:unit": "vitest --watch", @@ -33,10 +34,10 @@ "test-watch:storybook-vrt": "cross-env TARGET=storybook PWTEST_WATCH=1 playwright test", "test-ui:storybook-vrt": "cross-env TARGET=storybook playwright test --ui", - "electron:build": "cross-env VITE_TARGET=electron vite build && electron-builder --config electron-builder.config.js --publish never", "electron:serve": "cross-env VITE_TARGET=electron vite", + "electron:build": "cross-env VITE_TARGET=electron NODE_ENV=production vite build && electron-builder --config electron-builder.config.js --publish never", "browser:serve": "cross-env VITE_TARGET=browser vite", - "browser:build": "cross-env VITE_TARGET=browser vite build", + "browser:build": "cross-env VITE_TARGET=browser NODE_ENV=production vite build", "storybook": "storybook dev --port 6006", "storybook:build": "storybook build", @@ -114,6 +115,8 @@ "@vue/eslint-config-prettier": "9.0.0", "@vue/eslint-config-typescript": "13.0.0", "@vue/test-utils": "2.4.5", + "acorn": "8.14.0", + "acorn-walk": "8.3.4", "chromatic": "11.5.4", "cross-env": "7.0.3", "dotenv": "16.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e684071ae..fe2cd30cc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,12 @@ importers: '@vue/test-utils': specifier: 2.4.5 version: 2.4.5 + acorn: + specifier: 8.14.0 + version: 8.14.0 + acorn-walk: + specifier: 8.3.4 + version: 8.3.4 chromatic: specifier: 11.5.4 version: 11.5.4 @@ -1934,6 +1940,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + acorn@7.4.1: resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} engines: {node: '>=0.4.0'} @@ -7060,6 +7070,10 @@ snapshots: dependencies: acorn: 8.14.0 + acorn-walk@8.3.4: + dependencies: + acorn: 8.14.0 + acorn@7.4.1: {} acorn@8.14.0: {} diff --git a/tools/checkSuspiciousImports.mts b/tools/checkSuspiciousImports.mts new file mode 100644 index 0000000000..6cb86ae965 --- /dev/null +++ b/tools/checkSuspiciousImports.mts @@ -0,0 +1,209 @@ +/** + * 与えられたjsファイルのimport/requireを解析し、以下の条件に当てはまらないものがあれば警告を出力する: + * - Node.js 標準である + * - electron + * - try-catch内での明示的に許可されたパッケージ + * + * また、try-catch内での明示的に許可されたパッケージが使われていない場合も警告を出力する。 + * + * pnpm run check-suspicious-imports ./path/to/file.js のように単体でも実行可能。 + * + * NOTE: + * electron-builderはpnpmを一緒に使うとnode_modulesが壊れる問題があるが、 + * それはビルド後(dist/{main.js,preload.js})でパッケージがimport/requireされなければ問題ないため、 + * このスクリプトを使って問題がないかチェックする。 + * ref: https://github.com/VOICEVOX/voicevox/issues/2508 + */ + +import { builtinModules } from "module"; +import { styleText } from "util"; +import fs from "fs/promises"; +import yargs from "yargs"; +import { parse } from "acorn"; +import { ancestor as visitWithAncestor } from "acorn-walk"; +import { hideBin } from "yargs/helpers"; + +const defaultAllowedModules = [ + ...builtinModules.map((m) => `node:${m}`), + "electron", +]; + +type Import = { + path: string; + isInsideTryCatch: boolean; +}; +export type CheckSuspiciousImportsOptions = { + allowedModules?: string[]; + allowedInTryCatchModules?: string[]; + list?: boolean; +}; +export function checkSuspiciousImports( + file: string, + jsContent: string, + options: CheckSuspiciousImportsOptions = {}, +): void { + console.log(`Checking suspicious imports in ${file}...`); + const ast = parse(jsContent, { + ecmaVersion: "latest", + sourceType: "module", + }); + + const allImports: Import[] = []; + + const allowedModules = [ + ...defaultAllowedModules, + ...(options.allowedModules ?? []), + ]; + const allowedInTryCatchModules = options.allowedInTryCatchModules ?? []; + + visitWithAncestor(ast, { + ImportDeclaration(node) { + const importPath = node.source.value; + if (typeof importPath === "string") { + allImports.push({ + path: importPath, + isInsideTryCatch: false, + }); + } + }, + CallExpression(node, _, ancestors) { + const isInsideTryCatch = ancestors.some( + (ancestor) => ancestor.type === "TryStatement", + ); + if (node.callee.type === "Identifier" && node.callee.name === "require") { + const importPath = + node.arguments[0].type === "Literal" && node.arguments[0].value; + if (typeof importPath === "string") { + allImports.push({ + path: importPath, + isInsideTryCatch, + }); + } + } + }, + }); + + const normalizedImports: Import[] = []; + for (const importInfo of allImports) { + let path = importInfo.path; + if (builtinModules.includes(path) && !path.startsWith("node:")) { + path = `node:${path}`; + } + const existingImport = normalizedImports.find((i) => i.path === path); + if (existingImport) { + existingImport.isInsideTryCatch &&= importInfo.isInsideTryCatch; + } else { + normalizedImports.push({ + ...importInfo, + path, + }); + } + } + + const allowedImports = normalizedImports.filter((i) => + allowedModules.includes(i.path), + ); + const allowedInTryCatchImports = normalizedImports.filter( + (i) => + !allowedModules.includes(i.path) && + allowedInTryCatchModules.includes(i.path), + ); + const suspiciousImports = normalizedImports.filter( + (i) => + !allowedModules.includes(i.path) && + !allowedInTryCatchModules.includes(i.path), + ); + + for (const [color, title, imports] of [ + ["green", "Allowed", allowedImports], + ["yellow", "Allowed in try-catch", allowedInTryCatchImports], + ["red", "Suspicious", suspiciousImports], + ] as const) { + if (options.list) { + console.log(styleText(color, ` ${title}:`)); + for (const i of imports) { + console.log(styleText(color, ` ${i.path}`)); + } + if (imports.length === 0) { + console.log(styleText(color, ` (none)`)); + } + } else { + console.log(styleText(color, ` ${title}: ${imports.length}`)); + } + } + + if (suspiciousImports.length > 0) { + throw new Error( + `Suspicious imports found: ${suspiciousImports + .map((i) => i.path) + .join(", ")}`, + ); + } + + const unusedAllowedInTryCatchModules = allowedInTryCatchModules.filter( + (m) => !allowedInTryCatchImports.some((i) => i.path === m), + ); + if (options.list) { + console.log(styleText("yellow", ` Unused allowed in try-catch modules:`)); + for (const m of unusedAllowedInTryCatchModules) { + console.log(styleText("yellow", ` ${m}`)); + } + if (unusedAllowedInTryCatchModules.length === 0) { + console.log(styleText("yellow", ` (none)`)); + } + } else { + console.log( + styleText( + "yellow", + ` Unused allowed in try-catch modules: ${unusedAllowedInTryCatchModules.length}`, + ), + ); + } + if (unusedAllowedInTryCatchModules.length > 0) { + throw new Error( + `Some allowed in try-catch modules are not used in try-catch block: ${unusedAllowedInTryCatchModules.join( + ", ", + )}`, + ); + } +} + +if (import.meta.filename === process.argv[1]) { + const parser = yargs(hideBin(process.argv)) + .option("allowed-modules", { + type: "string", + array: true, + description: "Allowed modules", + alias: "a", + }) + .option("allowed-in-try-catch-modules", { + type: "string", + array: true, + description: "Allowed in try-catch modules", + alias: "t", + }) + .positional("files", { + type: "string", + description: "Files to check", + array: true, + }) + .strictCommands() + .demandCommand(1); + const args = await parser.parse(); + + for (const rawFile of args._) { + const file = rawFile.toString(); + fs.readFile(file, "utf-8") + .then((content) => { + checkSuspiciousImports(file, content, { + allowedModules: args.allowedModules, + allowedInTryCatchModules: args.allowedInTryCatchModules, + list: true, + }); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); + } +} diff --git a/vite.config.mts b/vite.config.mts index 1ea3fd40de..f091ae42cd 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -10,8 +10,14 @@ import { BuildOptions, defineConfig, loadEnv, Plugin } from "vite"; import { quasar } from "@quasar/vite-plugin"; import { z } from "zod"; +import { + checkSuspiciousImports, + CheckSuspiciousImportsOptions, +} from "./tools/checkSuspiciousImports.mjs"; + const isElectron = process.env.VITE_TARGET === "electron"; const isBrowser = process.env.VITE_TARGET === "browser"; +const isProduction = process.env.NODE_ENV === "production"; export default defineConfig((options) => { const mode = z @@ -48,7 +54,7 @@ export default defineConfig((options) => { : false; // ref: electronの起動をスキップしてデバッグ起動を軽くする - const skipLahnchElectron = + const skipLaunchElectron = mode === "test" || process.env.SKIP_LAUNCH_ELECTRON === "1"; return { @@ -83,18 +89,28 @@ export default defineConfig((options) => { }), isElectron && [ cleanDistPlugin(), + // TODO: 関数で切り出して共通化できる部分はまとめる electron([ { entry: "./backend/electron/main.ts", // ref: https://github.com/electron-vite/vite-plugin-electron/pull/122 onstart: ({ startup }) => { console.log("main process build is complete."); - if (!skipLahnchElectron) { + if (!skipLaunchElectron) { void startup([".", "--no-sandbox"]); } }, vite: { - plugins: [tsconfigPaths({ root: import.meta.dirname })], + plugins: [ + tsconfigPaths({ root: import.meta.dirname }), + isProduction && + checkSuspiciousImportsPlugin({ + allowedInTryCatchModules: [ + // systeminformationのoptionalな依存。try-catch内なので許可。 + "osx-temperature-sensor", + ], + }), + ], build: { outDir: path.resolve(import.meta.dirname, "dist"), sourcemap, @@ -105,12 +121,15 @@ export default defineConfig((options) => { // ref: https://electron-vite.github.io/guide/preload-not-split.html entry: "./backend/electron/preload.ts", onstart({ reload }) { - if (!skipLahnchElectron) { + if (!skipLaunchElectron) { reload(); } }, vite: { - plugins: [tsconfigPaths({ root: import.meta.dirname })], + plugins: [ + tsconfigPaths({ root: import.meta.dirname }), + isProduction && checkSuspiciousImportsPlugin({}), + ], build: { outDir: path.resolve(import.meta.dirname, "dist"), sourcemap, @@ -153,3 +172,20 @@ const injectBrowserPreloadPlugin = (): Plugin => { }, }; }; + +const checkSuspiciousImportsPlugin = ( + options: CheckSuspiciousImportsOptions, +): Plugin => { + return { + name: "check-suspicious-imports", + enforce: "post", + apply: "build", + writeBundle(_options, bundle) { + for (const [file, chunk] of Object.entries(bundle)) { + if (chunk.type === "chunk") { + checkSuspiciousImports(file, chunk.code, options); + } + } + }, + }; +};