diff --git a/package.json b/package.json index 59f2816b..111773b0 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "version": "0.0.0-development", "description": "Next.js Runtime Environment Configuration - Populates your environment at runtime rather than build time.", "main": "build/index.js", + "exports": { + ".": "./build/index.js", + "./eslint": "./build/eslint/index.js" + }, "scripts": { "lint": "eslint ./src", "lint:fix": "pnpm lint --fix", @@ -45,6 +49,8 @@ "@types/react-dom": "^18.3.5", "@typescript-eslint/eslint-plugin": "^8.28.0", "@typescript-eslint/parser": "^8.28.0", + "@typescript-eslint/rule-tester": "^8.53.1", + "@typescript-eslint/utils": "^8.53.1", "audit-ci": "^7.1.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^10.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6174e549..ca67071d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,12 @@ importers: '@typescript-eslint/parser': specifier: ^8.28.0 version: 8.28.0(eslint@8.57.1)(typescript@5.8.2) + '@typescript-eslint/rule-tester': + specifier: ^8.53.1 + version: 8.53.1(eslint@8.57.1)(typescript@5.8.2) + '@typescript-eslint/utils': + specifier: ^8.53.1 + version: 8.53.1(eslint@8.57.1)(typescript@5.8.2) audit-ci: specifier: ^7.1.0 version: 7.1.0 @@ -263,6 +269,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -690,10 +702,39 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/parser@8.53.1': + resolution: {integrity: sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.53.1': + resolution: {integrity: sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/rule-tester@8.53.1': + resolution: {integrity: sha512-+Xn/2Wd3AxB4LD1AYVLSDNYMCjbFrZAwt0rYgS/KmT7DjJr/TRMXVtS4eK4Gje8r4XUdWbiR/akse6aVYgqcCA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + '@typescript-eslint/scope-manager@8.28.0': resolution: {integrity: sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.53.1': + resolution: {integrity: sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.53.1': + resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.28.0': resolution: {integrity: sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -705,12 +746,22 @@ packages: resolution: {integrity: sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.53.1': + resolution: {integrity: sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.28.0': resolution: {integrity: sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/typescript-estree@8.53.1': + resolution: {integrity: sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.28.0': resolution: {integrity: sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -718,10 +769,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/utils@8.53.1': + resolution: {integrity: sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/visitor-keys@8.28.0': resolution: {integrity: sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.53.1': + resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -1133,6 +1195,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} @@ -1384,6 +1455,10 @@ packages: resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1463,6 +1538,15 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -2649,6 +2733,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pify@3.0.0: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} @@ -2871,6 +2959,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3123,6 +3216,10 @@ packages: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -3148,6 +3245,12 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-jest@29.3.0: resolution: {integrity: sha512-4bfGBX7Gd1Aqz3SyeDS9O276wEU/BInZxskPrbhZLyv+c1wskDCqDFMJQJLWrIr/fKoAH4GE5dKUlrdyvo+39A==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} @@ -3616,6 +3719,11 @@ snapshots: eslint: 8.57.1 eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.1': {} '@eslint/eslintrc@2.1.4': @@ -4195,11 +4303,55 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.53.1(eslint@8.57.1)(typescript@5.8.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.53.1 + '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.8.2) + '@typescript-eslint/visitor-keys': 8.53.1 + debug: 4.4.3 + eslint: 8.57.1 + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.53.1(typescript@5.8.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.8.2) + '@typescript-eslint/types': 8.53.1 + debug: 4.4.3 + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/rule-tester@8.53.1(eslint@8.57.1)(typescript@5.8.2)': + dependencies: + '@typescript-eslint/parser': 8.53.1(eslint@8.57.1)(typescript@5.8.2) + '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.8.2) + '@typescript-eslint/utils': 8.53.1(eslint@8.57.1)(typescript@5.8.2) + ajv: 6.12.6 + eslint: 8.57.1 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + - typescript + '@typescript-eslint/scope-manager@8.28.0': dependencies: '@typescript-eslint/types': 8.28.0 '@typescript-eslint/visitor-keys': 8.28.0 + '@typescript-eslint/scope-manager@8.53.1': + dependencies: + '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/visitor-keys': 8.53.1 + + '@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.8.2)': + dependencies: + typescript: 5.8.2 + '@typescript-eslint/type-utils@8.28.0(eslint@8.57.1)(typescript@5.8.2)': dependencies: '@typescript-eslint/typescript-estree': 8.28.0(typescript@5.8.2) @@ -4213,6 +4365,8 @@ snapshots: '@typescript-eslint/types@8.28.0': {} + '@typescript-eslint/types@8.53.1': {} + '@typescript-eslint/typescript-estree@8.28.0(typescript@5.8.2)': dependencies: '@typescript-eslint/types': 8.28.0 @@ -4227,6 +4381,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.53.1(typescript@5.8.2)': + dependencies: + '@typescript-eslint/project-service': 8.53.1(typescript@5.8.2) + '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.8.2) + '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/visitor-keys': 8.53.1 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.8.2) + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.28.0(eslint@8.57.1)(typescript@5.8.2)': dependencies: '@eslint-community/eslint-utils': 4.5.1(eslint@8.57.1) @@ -4238,11 +4407,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.53.1(eslint@8.57.1)(typescript@5.8.2)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.53.1 + '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.8.2) + eslint: 8.57.1 + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.28.0': dependencies: '@typescript-eslint/types': 8.28.0 eslint-visitor-keys: 4.2.0 + '@typescript-eslint/visitor-keys@8.53.1': + dependencies: + '@typescript-eslint/types': 8.53.1 + eslint-visitor-keys: 4.2.1 + '@ungap/structured-clone@1.3.0': {} abab@2.0.6: {} @@ -4709,6 +4894,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decimal.js@10.5.0: {} dedent@1.5.3: {} @@ -4982,6 +5171,8 @@ snapshots: eslint-visitor-keys@4.2.0: {} + eslint-visitor-keys@4.2.1: {} + eslint@8.57.1: dependencies: '@eslint-community/eslint-utils': 4.5.1(eslint@8.57.1) @@ -5130,6 +5321,10 @@ snapshots: dependencies: bser: 2.1.1 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -6429,6 +6624,8 @@ snapshots: picomatch@2.3.1: {} + picomatch@4.0.3: {} + pify@3.0.0: {} pirates@4.0.7: {} @@ -6687,6 +6884,8 @@ snapshots: semver@7.7.1: {} + semver@7.7.3: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -6956,6 +7155,11 @@ snapshots: dependencies: convert-hrtime: 5.0.0 + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -6979,6 +7183,10 @@ snapshots: dependencies: typescript: 5.8.2 + ts-api-utils@2.4.0(typescript@5.8.2): + dependencies: + typescript: 5.8.2 + ts-jest@29.3.0(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@22.13.14))(typescript@5.8.2): dependencies: bs-logger: 0.2.6 diff --git a/src/eslint/index.ts b/src/eslint/index.ts new file mode 100644 index 00000000..b9016d7e --- /dev/null +++ b/src/eslint/index.ts @@ -0,0 +1,23 @@ +import { noProcessEnvNextPublic } from './rules/no-process-env-next-public'; + +export const rules = { + 'no-process-env-next-public': noProcessEnvNextPublic, +}; + +const plugin = { + rules, + configs: { + get recommended() { + return { + plugins: { + 'next-runtime-env': plugin, + }, + rules: { + 'next-runtime-env/no-process-env-next-public': 'error' as const, + }, + }; + }, + }, +}; + +export default plugin; diff --git a/src/eslint/rules/no-process-env-next-public.spec.ts b/src/eslint/rules/no-process-env-next-public.spec.ts new file mode 100644 index 00000000..adcaf82f --- /dev/null +++ b/src/eslint/rules/no-process-env-next-public.spec.ts @@ -0,0 +1,105 @@ +// eslint-disable-next-line import/no-unresolved +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { noProcessEnvNextPublic } from './no-process-env-next-public'; + +// Polyfill for structuredClone which is not available in jsdom +if (typeof structuredClone !== 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).structuredClone = (obj: T): T => + JSON.parse(JSON.stringify(obj)); +} + +RuleTester.afterAll = afterAll; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-process-env-next-public', noProcessEnvNextPublic, { + valid: [ + { + name: 'using env() function for NEXT_PUBLIC_ variable', + code: ` + import { env } from 'next-runtime-env'; + const value = env('NEXT_PUBLIC_API_URL'); + `, + }, + { + name: 'accessing non-NEXT_PUBLIC_ variable via process.env', + code: `const nodeEnv = process.env.NODE_ENV;`, + }, + { + name: 'accessing private env variable', + code: `const secret = process.env.SECRET_KEY;`, + }, + { + name: 'destructuring non-NEXT_PUBLIC_ variable', + code: `const { NODE_ENV } = process.env;`, + }, + { + name: 'computed access without NEXT_PUBLIC_ prefix', + code: `const value = process.env['SECRET'];`, + }, + { + name: 'dynamic access without NEXT_PUBLIC_ in identifier', + code: ` + const key = 'FOO'; + const value = process.env[key]; + `, + }, + ], + invalid: [ + { + name: 'process.env.NEXT_PUBLIC_* with identifier property', + code: `const url = process.env.NEXT_PUBLIC_API_URL;`, + output: `import { env } from 'next-runtime-env'; +const url = env("NEXT_PUBLIC_API_URL");`, + errors: [{ messageId: 'useEnvFunction' }], + }, + { + name: 'process.env["NEXT_PUBLIC_*"] with computed literal property', + code: `const url = process.env['NEXT_PUBLIC_API_URL'];`, + output: `import { env } from 'next-runtime-env'; +const url = env("NEXT_PUBLIC_API_URL");`, + errors: [{ messageId: 'useEnvFunctionComputed' }], + }, + { + name: 'adds env to existing next-runtime-env import', + code: `import { PublicEnvScript } from 'next-runtime-env'; +const url = process.env.NEXT_PUBLIC_API_URL;`, + output: `import { PublicEnvScript, env } from 'next-runtime-env'; +const url = env("NEXT_PUBLIC_API_URL");`, + errors: [{ messageId: 'useEnvFunction' }], + }, + { + name: 'does not duplicate env import when already present', + code: `import { env } from 'next-runtime-env'; +const url = process.env.NEXT_PUBLIC_API_URL;`, + output: `import { env } from 'next-runtime-env'; +const url = env("NEXT_PUBLIC_API_URL");`, + errors: [{ messageId: 'useEnvFunction' }], + }, + { + name: 'destructuring NEXT_PUBLIC_ from process.env warns without fix', + code: `const { NEXT_PUBLIC_API_URL } = process.env;`, + errors: [{ messageId: 'useEnvFunctionDestructuring' }], + }, + { + name: 'multiple NEXT_PUBLIC_ destructured variables', + code: `const { NEXT_PUBLIC_API_URL, NEXT_PUBLIC_FOO } = process.env;`, + errors: [ + { messageId: 'useEnvFunctionDestructuring' }, + { messageId: 'useEnvFunctionDestructuring' }, + ], + }, + { + name: 'dynamic access with NEXT_PUBLIC_ in inline template literal', + code: `const value = process.env[\`NEXT_PUBLIC_\${suffix}\`];`, + errors: [{ messageId: 'useEnvFunctionDynamic' }], + }, + { + name: 'dynamic access with NEXT_PUBLIC_ concatenation', + code: `const value = process.env['NEXT_PUBLIC_' + varName];`, + errors: [{ messageId: 'useEnvFunctionDynamic' }], + }, + ], +}); diff --git a/src/eslint/rules/no-process-env-next-public.ts b/src/eslint/rules/no-process-env-next-public.ts new file mode 100644 index 00000000..906cd611 --- /dev/null +++ b/src/eslint/rules/no-process-env-next-public.ts @@ -0,0 +1,190 @@ +/* eslint-disable import/no-unresolved */ +import type { TSESLint } from '@typescript-eslint/utils'; +import { + AST_NODE_TYPES, + ESLintUtils, + TSESTree, +} from '@typescript-eslint/utils'; +/* eslint-enable import/no-unresolved */ + +const createRule = ESLintUtils.RuleCreator( + (name) => + `https://github.com/expatfile/next-runtime-env/blob/main/docs/eslint/${name}.md`, +); + +type MessageIds = + | 'useEnvFunction' + | 'useEnvFunctionComputed' + | 'useEnvFunctionDestructuring' + | 'useEnvFunctionDynamic'; + +export const noProcessEnvNextPublic = createRule<[], MessageIds>({ + name: 'no-process-env-next-public', + meta: { + type: 'suggestion', + docs: { + description: + 'Disallow direct access to process.env.NEXT_PUBLIC_* variables', + }, + fixable: 'code', + hasSuggestions: false, + messages: { + useEnvFunction: + 'Use env("{{varName}}") from next-runtime-env instead of process.env.{{varName}}', + useEnvFunctionComputed: + 'Use env("{{varName}}") from next-runtime-env instead of process.env["{{varName}}"]', + useEnvFunctionDestructuring: + 'Destructuring NEXT_PUBLIC_* from process.env is not recommended. Use env("{{varName}}") instead.', + useEnvFunctionDynamic: + 'Dynamic access to process.env with NEXT_PUBLIC_* variables should use env() from next-runtime-env', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + const { sourceCode } = context; + + function isProcessEnv(node: TSESTree.MemberExpression): boolean { + return ( + node.object.type === AST_NODE_TYPES.MemberExpression && + node.object.object.type === AST_NODE_TYPES.Identifier && + node.object.object.name === 'process' && + node.object.property.type === AST_NODE_TYPES.Identifier && + node.object.property.name === 'env' + ); + } + + function isNextPublicVar(name: string): boolean { + return name.startsWith('NEXT_PUBLIC_'); + } + + function hasEnvImport(): TSESTree.ImportDeclaration | null { + const program = sourceCode.ast; + const importNode = program.body.find( + (n): n is TSESTree.ImportDeclaration => + n.type === AST_NODE_TYPES.ImportDeclaration && + n.source.value === 'next-runtime-env', + ); + return importNode ?? null; + } + + function hasEnvSpecifier( + importDecl: TSESTree.ImportDeclaration, + ): TSESTree.ImportSpecifier | null { + const envSpecifier = importDecl.specifiers.find( + (specifier): specifier is TSESTree.ImportSpecifier => + specifier.type === AST_NODE_TYPES.ImportSpecifier && + specifier.imported.type === AST_NODE_TYPES.Identifier && + specifier.imported.name === 'env', + ); + return envSpecifier ?? null; + } + + function createFix( + fixer: TSESLint.RuleFixer, + node: TSESTree.MemberExpression, + varName: string, + ): TSESLint.RuleFix[] { + const fixes: TSESLint.RuleFix[] = []; + const existingImport = hasEnvImport(); + + if (!existingImport) { + fixes.push( + fixer.insertTextBefore( + sourceCode.ast.body[0], + "import { env } from 'next-runtime-env';\n", + ), + ); + } else if (!hasEnvSpecifier(existingImport)) { + const lastSpecifier = + existingImport.specifiers[existingImport.specifiers.length - 1]; + if (lastSpecifier) { + fixes.push(fixer.insertTextAfter(lastSpecifier, ', env')); + } + } + + fixes.push(fixer.replaceText(node, `env("${varName}")`)); + + return fixes; + } + + return { + MemberExpression(node) { + if (!isProcessEnv(node)) { + return; + } + + if ( + !node.computed && + node.property.type === AST_NODE_TYPES.Identifier + ) { + const varName = node.property.name; + if (isNextPublicVar(varName)) { + context.report({ + node, + messageId: 'useEnvFunction', + data: { varName }, + fix(fixer) { + return createFix(fixer, node, varName); + }, + }); + } + } else if ( + node.computed && + node.property.type === AST_NODE_TYPES.Literal && + typeof node.property.value === 'string' + ) { + const varName = node.property.value; + if (isNextPublicVar(varName)) { + context.report({ + node, + messageId: 'useEnvFunctionComputed', + data: { varName }, + fix(fixer) { + return createFix(fixer, node, varName); + }, + }); + } + } else if (node.computed) { + const text = sourceCode.getText(node.property); + if (text.includes('NEXT_PUBLIC_')) { + context.report({ + node, + messageId: 'useEnvFunctionDynamic', + }); + } + } + }, + + VariableDeclarator(node) { + if ( + node.init?.type === AST_NODE_TYPES.MemberExpression && + node.init.object.type === AST_NODE_TYPES.Identifier && + node.init.object.name === 'process' && + node.init.property.type === AST_NODE_TYPES.Identifier && + node.init.property.name === 'env' && + node.id.type === AST_NODE_TYPES.ObjectPattern + ) { + node.id.properties + .filter( + (prop): prop is TSESTree.Property => + prop.type === AST_NODE_TYPES.Property && + prop.key.type === AST_NODE_TYPES.Identifier && + isNextPublicVar(prop.key.name), + ) + .forEach((prop) => { + context.report({ + node: prop, + messageId: 'useEnvFunctionDestructuring', + data: { + varName: (prop.key as TSESTree.Identifier).name, + }, + }); + }); + } + }, + }; + }, +}); + +export default noProcessEnvNextPublic; diff --git a/src/script/public-env-script.tsx b/src/script/public-env-script.tsx index 1f0b8102..fb0c69c1 100644 --- a/src/script/public-env-script.tsx +++ b/src/script/public-env-script.tsx @@ -1,6 +1,6 @@ import { unstable_noStore as noStore } from 'next/cache'; -import { type FC } from 'react'; import { type ScriptProps } from 'next/script'; +import { type FC } from 'react'; import { getPublicEnv } from '../helpers/get-public-env'; import { type NonceConfig } from '../typings/nonce';