diff --git a/.changeset/nine-donuts-confess.md b/.changeset/nine-donuts-confess.md new file mode 100644 index 0000000000..7ae040c237 --- /dev/null +++ b/.changeset/nine-donuts-confess.md @@ -0,0 +1,9 @@ +--- +'astro': patch +--- + +Improve suppport for `import.meta.env`. + +Prior to this change, all variables defined in `.env` files had to include the `PUBLIC_` prefix, meaning that they could potentially be visible to the client if referenced. + +Now, Astro includes _any_ referenced variables defined in `.env` files on `import.meta.env` during server-side rendering, but only referenced `PUBLIC_` variables on the client. diff --git a/examples/env-vars/.env b/examples/env-vars/.env new file mode 100644 index 0000000000..dd89799f8f --- /dev/null +++ b/examples/env-vars/.env @@ -0,0 +1,2 @@ +DB_PASSWORD=foobar +PUBLIC_SOME_KEY=123 diff --git a/examples/env-vars/.gitignore b/examples/env-vars/.gitignore new file mode 100644 index 0000000000..c824674530 --- /dev/null +++ b/examples/env-vars/.gitignore @@ -0,0 +1,17 @@ +# build output +dist + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/examples/env-vars/.npmrc b/examples/env-vars/.npmrc new file mode 100644 index 0000000000..65922326b2 --- /dev/null +++ b/examples/env-vars/.npmrc @@ -0,0 +1,2 @@ +## force pnpm to hoist +shamefully-hoist = true diff --git a/examples/env-vars/.stackblitzrc b/examples/env-vars/.stackblitzrc new file mode 100644 index 0000000000..43798ecff8 --- /dev/null +++ b/examples/env-vars/.stackblitzrc @@ -0,0 +1,6 @@ +{ + "startCommand": "npm start", + "env": { + "ENABLE_CJS_IMPORTS": true + } +} \ No newline at end of file diff --git a/examples/env-vars/README.md b/examples/env-vars/README.md new file mode 100644 index 0000000000..686ccd77f1 --- /dev/null +++ b/examples/env-vars/README.md @@ -0,0 +1,9 @@ +# Astro Starter Kit: Environment Variables + +``` +npm init astro -- --template env-vars +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/env-vars) + +This example showcases Astro's support for Environment Variables. Please see Vite's [Env Variables and Modes](https://vitejs.dev/guide/env-and-mode.html) guide for more information. diff --git a/examples/env-vars/astro.config.mjs b/examples/env-vars/astro.config.mjs new file mode 100644 index 0000000000..67c95c2403 --- /dev/null +++ b/examples/env-vars/astro.config.mjs @@ -0,0 +1,8 @@ +// Full Astro Configuration API Documentation: +// https://docs.astro.build/reference/configuration-reference + +// @ts-check +export default /** @type {import('astro').AstroUserConfig} */ ({ + // Comment out "renderers: []" to enable Astro's default component support. + renderers: [], +}); diff --git a/examples/env-vars/package.json b/examples/env-vars/package.json new file mode 100644 index 0000000000..4a633216d5 --- /dev/null +++ b/examples/env-vars/package.json @@ -0,0 +1,14 @@ +{ + "name": "@example/env-vars", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "devDependencies": { + "astro": "^0.23.0-next.10" + } +} diff --git a/examples/env-vars/public/favicon.ico b/examples/env-vars/public/favicon.ico new file mode 100644 index 0000000000..578ad458b8 Binary files /dev/null and b/examples/env-vars/public/favicon.ico differ diff --git a/examples/env-vars/sandbox.config.json b/examples/env-vars/sandbox.config.json new file mode 100644 index 0000000000..9178af77d7 --- /dev/null +++ b/examples/env-vars/sandbox.config.json @@ -0,0 +1,11 @@ +{ + "infiniteLoopProtection": true, + "hardReloadOnChange": false, + "view": "browser", + "template": "node", + "container": { + "port": 3000, + "startScript": "start", + "node": "14" + } +} diff --git a/examples/env-vars/src/env.d.ts b/examples/env-vars/src/env.d.ts new file mode 100644 index 0000000000..a1befd0f03 --- /dev/null +++ b/examples/env-vars/src/env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly DB_PASSWORD: string; + readonly PUBLIC_SOME_KEY: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/examples/env-vars/src/pages/index.astro b/examples/env-vars/src/pages/index.astro new file mode 100644 index 0000000000..0d19b9a46d --- /dev/null +++ b/examples/env-vars/src/pages/index.astro @@ -0,0 +1,21 @@ +--- +const { SSR, DB_PASSWORD, PUBLIC_SOME_KEY } = import.meta.env; + +// DB_PASSWORD is available because we're running on the server +console.log({ SSR, DB_PASSWORD }); + +// PUBLIC_SOME_KEY is available everywhere +console.log({ SSR, PUBLIC_SOME_KEY }); +--- + + + + + + Astro + + +

Hello, Environment Variables!

+ + + diff --git a/examples/env-vars/src/scripts/client.ts b/examples/env-vars/src/scripts/client.ts new file mode 100644 index 0000000000..05961d3995 --- /dev/null +++ b/examples/env-vars/src/scripts/client.ts @@ -0,0 +1,9 @@ +(() => { + const { SSR, DB_PASSWORD, PUBLIC_SOME_KEY } = import.meta.env; + + // DB_PASSWORD is NOT available because we're running on the client + console.log({ SSR, DB_PASSWORD }); + + // PUBLIC_SOME_KEY is available everywhere + console.log({ SSR, PUBLIC_SOME_KEY }); +})() diff --git a/examples/env-vars/tsconfig.json b/examples/env-vars/tsconfig.json new file mode 100644 index 0000000000..c9d2331c8d --- /dev/null +++ b/examples/env-vars/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "module": "ES2020" + } +} diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index d7aaec990f..044e4852ea 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -10,6 +10,7 @@ import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.j import configAliasVitePlugin from '../vite-plugin-config-alias/index.js'; import markdownVitePlugin from '../vite-plugin-markdown/index.js'; import jsxVitePlugin from '../vite-plugin-jsx/index.js'; +import envVitePlugin from '../vite-plugin-env/index.js'; import { resolveDependency } from './util.js'; // Some packages are just external, and that’s the way it goes. @@ -54,6 +55,7 @@ export async function createVite(inlineConfig: ViteConfigWithSSR, { astroConfig, // The server plugin is for dev only and having it run during the build causes // the build to run very slow as the filewatcher is triggered often. mode === 'dev' && astroViteServerPlugin({ config: astroConfig, logging }), + envVitePlugin({ config: astroConfig }), markdownVitePlugin({ config: astroConfig }), jsxVitePlugin({ config: astroConfig, logging }), astroPostprocessVitePlugin({ config: astroConfig }), diff --git a/packages/astro/src/vite-plugin-env/README.md b/packages/astro/src/vite-plugin-env/README.md new file mode 100644 index 0000000000..0e2a7d7d5c --- /dev/null +++ b/packages/astro/src/vite-plugin-env/README.md @@ -0,0 +1,11 @@ +# vite-plugin-env + +Improves Vite's [Env Variables](https://vitejs.dev/guide/env-and-mode.html#env-files) support to include **private** env variables during Server-Side Rendering (SSR) but never in client-side rendering (CSR). + +Note that for added security, this plugin does not include **globally available env variable** that exist on `process.env`. It only loads variables defined in your local `.env` files. + +Because of this, `MY_CLI_ARG` will never be added to `import.meta.env` during SSR or CSR. + +```shell +MY_CLI_ARG=1 npm run dev +``` diff --git a/packages/astro/src/vite-plugin-env/index.ts b/packages/astro/src/vite-plugin-env/index.ts new file mode 100644 index 0000000000..803b97a147 --- /dev/null +++ b/packages/astro/src/vite-plugin-env/index.ts @@ -0,0 +1,83 @@ +import type * as vite from 'vite'; +import type { AstroConfig } from '../@types/astro'; +import MagicString from 'magic-string'; +import { fileURLToPath } from 'url'; +import { loadEnv } from 'vite'; + +interface EnvPluginOptions { + config: AstroConfig; +} + +function getPrivateEnv(viteConfig: vite.ResolvedConfig, astroConfig: AstroConfig) { + let envPrefixes: string[] = ['PUBLIC_']; + if (viteConfig.envPrefix) { + envPrefixes = Array.isArray(viteConfig.envPrefix) ? viteConfig.envPrefix : [viteConfig.envPrefix]; + } + const fullEnv = loadEnv(viteConfig.mode, viteConfig.envDir ?? fileURLToPath(astroConfig.projectRoot), ''); + const privateKeys = Object.keys(fullEnv).filter(key => { + // don't expose any variables also on `process.env` + // note: this filters out `CLI_ARGS=1` passed to node! + if (typeof process.env[key] !== 'undefined') return false; + + // don't inject `PUBLIC_` variables, Vite handles that for us + for (const envPrefix of envPrefixes) { + if (key.startsWith(envPrefix)) return false; + } + + // Otherwise, this is a private variable defined in an `.env` file + return true; + }) + if (privateKeys.length === 0) { + return null; + } + return Object.fromEntries(privateKeys.map(key => [key, fullEnv[key]])); +} + +function referencesPrivateKey(source: string, privateEnv: Record) { + for (const key of Object.keys(privateEnv)) { + if (source.includes(key)) return true; + } + return false; +} + +export default function envVitePlugin({ config: astroConfig }: EnvPluginOptions): vite.PluginOption { + let privateEnv: Record | null; + let config: vite.ResolvedConfig; + return { + name: 'astro:vite-plugin-env', + enforce: 'pre', + + configResolved(resolvedConfig) { + config = resolvedConfig; + if (config.envPrefix) { + } + }, + + async transform(source, id, options) { + const ssr = options?.ssr === true; + if (!ssr) return source; + if (!source.includes('import.meta')) return source; + if (!/\benv\b/.test(source)) return source; + + if (typeof privateEnv === 'undefined') { + privateEnv = getPrivateEnv(config, astroConfig); + } + if (!privateEnv) return source; + if (!referencesPrivateKey(source, privateEnv)) return source; + + const s = new MagicString(source); + // prettier-ignore + s.prepend(`import.meta.env = new Proxy(import.meta.env, {` + + `get(target, prop, reciever) {` + + `const PRIVATE = ${JSON.stringify(privateEnv)};` + + `if (typeof PRIVATE[prop] !== 'undefined') {` + + `return PRIVATE[prop];` + + `}` + + `return Reflect.get(target, prop, reciever);` + + `}` + + `});\n`); + + return s.toString(); + }, + }; +} diff --git a/packages/astro/test/astro-envs.test.js b/packages/astro/test/astro-envs.test.js index 56b0471da8..22a4502dc3 100644 --- a/packages/astro/test/astro-envs.test.js +++ b/packages/astro/test/astro-envs.test.js @@ -14,10 +14,10 @@ describe('Environment Variables', () => { expect(true).to.equal(true); }); - it('does render public env, does not render private env', async () => { + it('does render public env and private env', async () => { let indexHtml = await fixture.readFile('/index.html'); - expect(indexHtml).to.not.include('CLUB_33'); + expect(indexHtml).to.include('CLUB_33'); expect(indexHtml).to.include('BLUE_BAYOU'); }); @@ -39,6 +39,27 @@ describe('Environment Variables', () => { }) ); - expect(found).to.equal(true, 'found the env variable in the JS build'); + expect(found).to.equal(true, 'found the public env variable in the JS build'); + }); + + it('does not include private env in client-side JS', async () => { + let dirs = await fixture.readdir('/assets'); + let found = false; + + // Look in all of the .js files to see if the public env is inlined. + // Testing this way prevents hardcoding expected js files. + // If we find it in any of them that's good enough to know its NOT working. + await Promise.all( + dirs.map(async (path) => { + if (path.endsWith('.js')) { + let js = await fixture.readFile(`/assets/${path}`); + if (js.includes('CLUB_33')) { + found = true; + } + } + }) + ); + + expect(found).to.equal(false, 'found the private env variable in the JS build'); }); }); diff --git a/packages/astro/test/fixtures/astro-envs/src/components/Client.vue b/packages/astro/test/fixtures/astro-envs/src/components/Client.vue index 01bae708aa..7162c56329 100644 --- a/packages/astro/test/fixtures/astro-envs/src/components/Client.vue +++ b/packages/astro/test/fixtures/astro-envs/src/components/Client.vue @@ -9,6 +9,7 @@ export default { data() { return { PUBLIC_PLACE: import.meta.env.PUBLIC_PLACE, + SECRET_PLACE: import.meta.env.SECRET_PLACE, }; }, }; diff --git a/packages/astro/test/fixtures/astro-envs/src/pages/index.astro b/packages/astro/test/fixtures/astro-envs/src/pages/index.astro index b2cb02b9d5..f71c11db75 100644 --- a/packages/astro/test/fixtures/astro-envs/src/pages/index.astro +++ b/packages/astro/test/fixtures/astro-envs/src/pages/index.astro @@ -3,4 +3,4 @@ import Client from '../components/Client.vue'; --- {import.meta.env.PUBLIC_PLACE} {import.meta.env.SECRET_PLACE} - \ No newline at end of file + diff --git a/scripts/memory/index.js b/scripts/memory/index.js index 2729c76311..29b20832dc 100644 --- a/scripts/memory/index.js +++ b/scripts/memory/index.js @@ -75,9 +75,9 @@ let medianOfAll = median(sizes); // If the trailing average is higher than the median, see if it's more than 5% higher if (averageOfLastThirty > medianOfAll) { let percentage = Math.abs(averageOfLastThirty - medianOfAll) / medianOfAll; - if (percentage > 0.05) { + if (percentage > 0.1) { throw new Error( - `The average towards the end (${prettyBytes(averageOfLastThirty)}) is more than 5% higher than the median of all runs (${prettyBytes( + `The average towards the end (${prettyBytes(averageOfLastThirty)}) is more than 10% higher than the median of all runs (${prettyBytes( medianOfAll )}). This tells us that memory continues to grow and a leak is likely.` );