Vite build前に環境別のファイルを生成する方法

はじめに

Vue.js(Nuxt.js)やReact(Next.js)のプロジェクトで、Viteを使ってbuildする前に、環境別でファイルを生成したい場合があります。
例えば、robots.txtで本番環境ではGooglebotなどのクローラのアクセスを許可したいが、ステージング環境(テスト環境)ではアクセスを拒否したい場合や、サイトマップ(sitemap.xml)を本番環境のみ生成したい場合などです。
リリース時に手動でファイルを配置すればええやんと言われそうですが…
環境別でやらなければならないことが、微妙に違ったりすると、ミスをしやすいので、自動化できるのであれば、自動化すべきです。
そんなわけで、今回はbuild前に環境別のファイルを生成する方法の紹介です。

とりあえずファイルを生成してみる

「環境別」はひとまず考慮せず、build前にpublicフォルダにrobots.txtを生成してみましょう。
今回は、プロジェクトフォルダの直下にscriptsフォルダを作成して、generate_robots_txt.jsを以下のように作成します。

generate_robots_txt.js
import { writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';

const createRobotsTxt = () => {
  return `User-agent: *\nDisallow: /\n`;
};

export const generateRobotsTxt = () => {
  const filename = fileURLToPath(import.meta.url);
  const foldername = dirname(filename);
  const outputname = join(foldername, '../public/robots.txt');
  writeFileSync(outputname, createRobotsTxt());
};

import.meta.urlはそのファイル自身のURLで、自分の環境(Windows)の場合は、以下のようになります。

file:///C:/VSCode/generating-files-by-env/scripts/generate_robots_txt.js

fileURLToPath関数で、このURLをパスに変換し、dirnameメソッドでgenerate_robots_txt.jsが配置されているフォルダのパスを取得すると、以下のscriptsフォルダのパスが取得できます。

C:\VSCode\generating-files-by-env\scripts

ここを起点として、joinメソッドで、'../public/robots.txt'と結合することで、以下のパスを出力先のパス(outputname)として作成しています。

C:\VSCode\generating-files-by-env\public\robots.txt

fileURLToPath関数やdirnameメソッド、joinメソッドを使わず、上記の絶対パスや'./public/robots.txt'を直接出力先のパスとしても問題なく動作しますが、プロジェクトのパスやコマンド実行時のディレクトリが異なるケースや環境によってパス区切りが変わってくることなどを考慮して、上記のような出力先のパスを作成する方法をとっています。
最後にwriteFileSync関数でcreateRobotsTxt関数で作成した文字列をrobots.txtに出力するようにして、このスクリプトは完成です。
ただ、これだけですと、generateRobotsTxt関数は実行されませんので、scriptsフォルダに以下のpre_build.jsを作成して、exportしたgenerateRobotsTxt関数を実行するようにします。

pre_build.js
import { generateRobotsTxt } from './generate_robots_txt.js';

generateRobotsTxt();

これでスクリプトは完成しました。
実際にこのスクリプトを動かしてみましょう。
パッケージ管理システムがnpmyarnの場合は、buildにpreプレフィックスをつけたprebuildを以下のようにscriptsに追加すると、build前に処理を実行できます。
ここでは「node scripts/pre_build.js」で、先ほどのスクリプトを動かすようにします。
※yarnでpreプレフィックスは使えないとの記事もあるようでしたが、現時点(2024/03/29)の最新バージョン(1.22.22)ではうまくいくことを確認しています。

package.json
{
  "name": "generating-files-by-env",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
+   "prebuild": "node scripts/pre_build.js",
    "build": "run-p type-check \"build-only {@}\" --",
    "preview": "vite preview",
    "build-only": "vite build",
    "type-check": "vue-tsc --build --force"
  },
  "dependencies": {
    "vue": "^3.4.21"
  },
  "devDependencies": {
    "@tsconfig/node20": "^20.1.2",
    "@types/node": "^20.11.28",
    "@vitejs/plugin-vue": "^5.0.4",
    "@vue/tsconfig": "^0.5.1",
    "npm-run-all2": "^6.1.2",
    "typescript": "~5.4.0",
    "vite": "^5.1.6",
    "vue-tsc": "^2.0.6"
  }
}

あとは、いつも通り以下のコマンドでbuildを実行するだけです。

npm run build

または

yarn run build

buildの実行
作成したスクリプトでpublicフォルダにrobots.txtが生成できました!
build前にpublicフォルダにrobots.txtを生成していますので、当然、distフォルダに同じrobots.txtがコピーされます。

環境別で動くように変更する

さて、ここまでうまくいきましたので、今度はrobots.txtを環境別で出し分けできるように変えてみましょう。
先ほどは最後にpackage.jsonの変更を行いましたが、今度は先にpackage.jsonで以下のような変更を行います。

package.json
{
  "name": "generating-files-by-env",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "prebuild": "node scripts/pre_build.js",
    "build": "run-p type-check \"build-only {@}\" --",
+   "prebuild:staging": "node scripts/pre_build.js --mode staging",
+   "build:staging": "run-p type-check \"build-only:staging {@}\" --",
+   "prebuild:production": "node scripts/pre_build.js --mode production",
+   "build:production": "run-p type-check \"build-only:production {@}\" --",
    "preview": "vite preview",
    "build-only": "vite build",
+   "build-only:staging": "vite build --mode staging",
+   "build-only:production": "vite build --mode production",
    "type-check": "vue-tsc --build --force"
  },
  "dependencies": {
    "vue": "^3.4.21"
  },
  "devDependencies": {
    "@tsconfig/node20": "^20.1.2",
    "@types/node": "^20.11.28",
    "@vitejs/plugin-vue": "^5.0.4",
    "@vue/tsconfig": "^0.5.1",
    "npm-run-all2": "^6.1.2",
    "typescript": "~5.4.0",
    "vite": "^5.1.6",
    "vue-tsc": "^2.0.6"
  }
}

上記で、以下のようなステージング環境用(:staging)と本番環境用(:production)のbuildコマンドを使えるようにしています。

npm run build:staging
npm run build:production

または

yarn run build:staging
yarn run build:production

この時点で上記コマンドを実行しても通常のbuildと変わりはありません。
これからprebuild:stagingの「--mode staging」やprebuild:productionの「--mode production」というオプションフラグによって、環境変数を取得し、処理を分けていきます。
build-only:staging、build-only:productionの方につけたオプションフラグは今回は使用しませんが、多くの場合、build時も環境変数を取得して使うことになるため、prebuild時と同様にオプションフラグ「--mode staging」、「--mode production」をつけています。
次に、処理を分けるための環境設定ファイル(.env)をプロジェクトフォルダ直下(package.jsonと同階層)に以下のように作成します。

.env
VITE_ROBOTS_TXT_ALLOW = 'false'
.env.staging
VITE_ROBOTS_TXT_ALLOW = 'false'
.env.production
VITE_ROBOTS_TXT_ALLOW = 'true'

.envファイルで環境変数の値をtrueと設定してもboolean型ではなく文字列(string型)となるため、ここではあえてtrue、falseではなく、'true'、'false'としています。
環境変数名はVITE_ROBOTS_TXT_ALLOWである必要はなく、プレフィックスとして「VITE_」がついていれば別の変数名でも問題ありません。
プレフィックスとして「VITE_」がついていれば、Viteで利用できる環境変数として認識してくれるようになります。
ここからpre_build.js、generate_robots_txt.jsの修正に入りますが、その前に以下のコマンドでcommanderをインストールします。

npm install commander --save-dev

または

yarn add commander --dev

インストールしたcommanderからprogramをインポートすると、オプションフラグの設定と取得が行えるようになりますので、pre_build.jsをprogramを使って、以下のように修正します。

pre_build.js
import { program } from "commander";
import { generateRobotsTxt } from './generate_robots_txt.js';

program.option("--mode <mode>", "server environment", "development");
program.parse();
const options = program.opts();

generateRobotsTxt(options.mode);

もちろん「--mode」のようなオプションフラグを使わずに、「node scripts/pre_build.js staging」のようにして、process.argvでコマンドライン引数の「staging」を取得するつくりとしても問題ありませんが、オプションフラグを使っておいた方が拡張しやすくなるため、オプションフラグを使う方法をとるようにしています。
最後にgenerate_robots_txt.jsを以下のようなスクリプトに修正します。

generate_robots_txt.js
import { writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { loadEnv } from 'vite';

const createRobotsTxt = (mode) => {
  const env = loadEnv(mode, process.cwd());
  if (env.VITE_ROBOTS_TXT_ALLOW === 'true') {
    return `User-agent: *\nAllow: /\n`;
  } else {
    return `User-agent: *\nDisallow: /\n`;
  }
};

export const generateRobotsTxt = (mode) => {
  const filename = fileURLToPath(import.meta.url);
  const foldername = dirname(filename);
  const outputname = join(foldername, '../public/robots.txt');
  writeFileSync(outputname, createRobotsTxt(mode));
};

modeで「if (mode === 'production')」のように分岐させてもよいのですが、ここでは保守性をよくするために環境設定ファイルで定義した環境変数で分岐させるようにしています。
vue-cliなどの他のビルドツールでは、process.env.VITE_ROBOTS_TXT_ALLOWのように環境設定ファイルで定義した環境変数にアクセスできていましたが、Viteでは取得できません。また、ビルド時に参照できるimport.meta.envもprebuild時には参照できないため、環境設定ファイルで定義した環境変数を参照したい場合には、loadEnv関数を使います。
loadEnv関数の第二引数に渡しているprocess.cwd()はコマンドを実行している現在のディレクトリで、このディレクトリ直下にある環境設定ファイル(.env)を参照できます。
参照する環境設定ファイルは第一引数のmodeによって変わり、modeがstagingであれば.env.stagingを参照でき、modeがproductionであれば.env.productionを参照できます。
今回は環境変数VITE_ROBOTS_TXT_ALLOWの値が'true'のときはクローラのアクセスを許可するため、robots.txtに書き出す文字列を「'User-agent: *\nAllow: /\n'」としています。
クローラのアクセスを許可したい場合は、「Allow: /」または「Disallow:」としますが、どちらがよいと考えるかは人によって異なり、「Allow: /」はGoogleやbingなどの主要なクローラ以外では対応していない場合もあるため、「Disallow:」の方がよいとしている方もいます。
ですが………私の場合はAllowがクローラに認識されなくてもクローラを拒否することにはならないことと、何より拒否する設定である「Disallow: /」と間違えやすいことから「Allow: /」を使っています。
さて、ここまでできたら、package.jsonで定義した以下のコマンドでステージング環境用(:staging)と本番環境用(:production)のbuildコマンドを実行するだけです。

npm run build:staging
npm run build:production

または

yarn run build:staging
yarn run build:production

本番環境buildの実行
「npm run build:staging」では以下のようにクローラを拒否するrobots.txtが生成されます。

robots.txt
User-agent: *
Disallow: /

「npm run build:production」では以下のようにクローラを許可するrobots.txtが生成されます。

robots.txt
User-agent: *
Allow: /

作成したスクリプトでpublicフォルダに環境別のrobots.txtが生成できました!

せっかくなのでTypeScriptで

以上で問題なく環境別のrobots.txtが生成できているので、ここからは蛇足ですが、せっかくなのでスクリプトをTypeScriptに変更してみましょう。
スクリプトgenerate_robots_txt.ts、pre_build.tsの変更内容は単純で、以下のように拡張子をtsにして、引数に型を付けただけで完了です。

generate_robots_txt.ts
import { writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { loadEnv } from 'vite';

const createRobotsTxt = (mode: string) => {
  const env = loadEnv(mode, process.cwd());
  if (env.VITE_ROBOTS_TXT_ALLOW === 'true') {
    return `User-agent: *\nAllow: /\n`;
  } else {
    return `User-agent: *\nDisallow: /\n`;
  }
};

export const generateRobotsTxt = (mode: string) => {
  const filename = fileURLToPath(import.meta.url);
  const foldername = dirname(filename);
  const outputname = join(foldername, '../public/robots.txt');
  writeFileSync(outputname, createRobotsTxt(mode));
};
pre_build.ts
import { program } from "commander";
import { generateRobotsTxt } from './generate_robots_txt.ts';

program.option("--mode <mode>", "server environment", "development");
program.parse();
const options = program.opts();

generateRobotsTxt(options.mode);

ただし、nodeコマンドはTypeScriptのファイルには対応していませんので、「node scripts/pre_build.ts」を実行すると以下のような例外エラー(ERR_UNKNOWN_FILE_EXTENSION)となります。

node:internal/modules/esm/get_format:160
  throw new ERR_UNKNOWN_FILE_EXTENSION(ext, filepath);
        ^

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for C:\VSCode\generating-files-by-env\scripts\pre_build.ts
    at Object.getFileProtocolModuleFormat [as file:] (node:internal/modules/esm/get_format:160:9)
    at defaultGetFormat (node:internal/modules/esm/get_format:203:36)
    at defaultLoad (node:internal/modules/esm/load:143:22)
    at async ModuleLoader.load (node:internal/modules/esm/loader:403:7)
    at async ModuleLoader.moduleProvider (node:internal/modules/esm/loader:285:45)
    at async link (node:internal/modules/esm/module_job:78:21) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

TypeScriptファイルを実行するためのnodeランタイムはいくつかありますが、Viteでプロジェクトを作成しているのであれば、vite-nodeがおススメです。
vite-node以外では、tsimpでも現在(2024/03/29時点)のNodeの最新の安定バージョン(LTS)v20.12.0で動作することは確認できていますが、単体テストツールとして、vitestをプロジェクトにインストールしている場合には、vitestにvite-nodeが含まれていますので、新たにnodeランタイムをインストールする必要がないは魅力的です。
Viteでプロジェクトを作成している場合、vitestもインストールされているかと思いますが、vitestを使わず、vite-nodeだけをインストールしたい場合は、以下のコマンドでvite-nodeをインストールしてください。

npm install vite-node --save-dev

または

yarn add vite-node --dev

最後に、以下のようにnodeコマンドをvite-nodeコマンドでスクリプトを実行するように書き換えます。

package.json
{
  "name": "generating-files-by-env",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
-   "prebuild": "node scripts/pre_build.js",
+   "prebuild": "vite-node scripts/pre_build.ts",
    "build": "run-p type-check \"build-only {@}\" --",
-   "prebuild:staging": "node scripts/pre_build.js --mode staging",
+   "prebuild:staging": "vite-node scripts/pre_build.ts --mode staging",
    "build:staging": "run-p type-check \"build-only:staging {@}\" --",
-   "prebuild:production": "node scripts/pre_build.js --mode production",
+   "prebuild:production": "vite-node scripts/pre_build.ts --mode production",
    "build:production": "run-p type-check \"build-only:production {@}\" --",
    "preview": "vite preview",
    "build-only": "vite build",
    "build-only:staging": "vite build --mode staging",
    "build-only:production": "vite build --mode production",
    "type-check": "vue-tsc --build --force"
  },
  "dependencies": {
    "vue": "^3.4.21"
  },
  "devDependencies": {
    "@tsconfig/node20": "^20.1.2",
    "@types/node": "^20.11.28",
    "@vitejs/plugin-vue": "^5.0.4",
    "@vue/tsconfig": "^0.5.1",
    "commander": "^12.0.0",
    "npm-run-all2": "^6.1.2",
    "typescript": "~5.4.0",
    "vite": "^5.1.6",
    "vite-node": "^1.4.0",
    "vue-tsc": "^2.0.6"
  }
}

再度、以下のコマンドでステージング環境用(:staging)と本番環境用(:production)のbuildコマンドを実行してみましょう。

npm run build:staging
npm run build:production

または

yarn run build:staging
yarn run build:production

本番環境buildの実行(TypeScript)
TypeScriptでもpublicフォルダに環境別のrobots.txtが生成できました!

まとめ

以上がvite build前に環境別のファイルを生成する方法になります。
今回の最終的なソースコードは以下に上げていますので、参考としてください。
https://github.com/y-kashima-iandc/generating-files-by-env
その他、説明しきれなかった注意点も以下に記載していますので、合わせてご確認ください。

注意点

今回のスクリプトを使用する場合には、以下2点にご注意ください。
①robots.txtのバージョン管理
publicフォルダは通常、Gitによるバージョン管理の対象です。
publicフォルダにスクリプトで生成されたrobots.txtも、そのままではGitによるバージョン管理の対象となりますので、バージョン管理から除外したい場合には、.gitignoreで以下の記述を追記してください。

.gitignore
# prebuild時、pre_build.tsで自動生成するため、git管理から除外
/public/robots.txt

②writeFileSyncの使用
writeFileSyncはファイルの生成が行える便利な関数ですが、それゆえに使い所を間違えると脆弱性の原因となる関数でもあります。
writeFileSyncの第一引数がユーザーの入力によって変更できる可能性があった場合、ソースコードなどのアクセスできてはいけないパスにアクセスできる可能性がありますので、SAST(静的アプリケーションセキュリティテスト)によっては、「detect-non-literal-fs-filename」のワーニングが出ます。
今回は「import.meta.url」をベースにパスを生成しており、かつ、あくまでもbuild時に利用するスクリプト内で利用しているだけで、アプリ内には組み込まれないことを確認し、私の方では、利用しても問題ないと判断して利用しています。
上記のように、対応された方が問題ないと判断した場合にかぎり、「eslint-disable-next-line」のコメントでワーニングから除外することもできます。
※有効なコメントは利用しているSASTによって異なる可能性があります。

generate_robots_txt.ts
export const generateRobotsTxt = (mode: string) => {
  const filename = fileURLToPath(import.meta.url);
  const foldername = dirname(filename);
  const outputname = join(foldername, '../public/robots.txt');
+ // ユーザーの入力がここに到達することはない
+ // eslint-disable-next-line detect-non-literal-fs-filename
  writeFileSync(outputname, createRobotsTxt(mode));
};

参考文献

この記事は以下の情報を参考にして執筆しました。

株式会社アイアンドシー Tech Blog

Discussion