Expose private .env
variables to import.meta.env
during SSR (#2612)
* chore(examples): add env-vars example * feat: improve import.meta.env support * chore: add changeset * test: update astro-envs test * refactor: cleanup code based on feedback * fix: import.meta guard * fix: update memory test threshold to 10%
This commit is contained in:
parent
37d4dd8d57
commit
39cbe50085
21 changed files with 248 additions and 6 deletions
9
.changeset/nine-donuts-confess.md
Normal file
9
.changeset/nine-donuts-confess.md
Normal file
|
@ -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.
|
2
examples/env-vars/.env
Normal file
2
examples/env-vars/.env
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
DB_PASSWORD=foobar
|
||||||
|
PUBLIC_SOME_KEY=123
|
17
examples/env-vars/.gitignore
vendored
Normal file
17
examples/env-vars/.gitignore
vendored
Normal file
|
@ -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
|
2
examples/env-vars/.npmrc
Normal file
2
examples/env-vars/.npmrc
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
## force pnpm to hoist
|
||||||
|
shamefully-hoist = true
|
6
examples/env-vars/.stackblitzrc
Normal file
6
examples/env-vars/.stackblitzrc
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"startCommand": "npm start",
|
||||||
|
"env": {
|
||||||
|
"ENABLE_CJS_IMPORTS": true
|
||||||
|
}
|
||||||
|
}
|
9
examples/env-vars/README.md
Normal file
9
examples/env-vars/README.md
Normal file
|
@ -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.
|
8
examples/env-vars/astro.config.mjs
Normal file
8
examples/env-vars/astro.config.mjs
Normal file
|
@ -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: [],
|
||||||
|
});
|
14
examples/env-vars/package.json
Normal file
14
examples/env-vars/package.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
BIN
examples/env-vars/public/favicon.ico
Normal file
BIN
examples/env-vars/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
11
examples/env-vars/sandbox.config.json
Normal file
11
examples/env-vars/sandbox.config.json
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"infiniteLoopProtection": true,
|
||||||
|
"hardReloadOnChange": false,
|
||||||
|
"view": "browser",
|
||||||
|
"template": "node",
|
||||||
|
"container": {
|
||||||
|
"port": 3000,
|
||||||
|
"startScript": "start",
|
||||||
|
"node": "14"
|
||||||
|
}
|
||||||
|
}
|
10
examples/env-vars/src/env.d.ts
vendored
Normal file
10
examples/env-vars/src/env.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly DB_PASSWORD: string;
|
||||||
|
readonly PUBLIC_SOME_KEY: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
21
examples/env-vars/src/pages/index.astro
Normal file
21
examples/env-vars/src/pages/index.astro
Normal file
|
@ -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 });
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<title>Astro</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Hello, Environment Variables!</h1>
|
||||||
|
<script type="module" src="/src/scripts/client.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
9
examples/env-vars/src/scripts/client.ts
Normal file
9
examples/env-vars/src/scripts/client.ts
Normal file
|
@ -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 });
|
||||||
|
})()
|
6
examples/env-vars/tsconfig.json
Normal file
6
examples/env-vars/tsconfig.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"module": "ES2020"
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.j
|
||||||
import configAliasVitePlugin from '../vite-plugin-config-alias/index.js';
|
import configAliasVitePlugin from '../vite-plugin-config-alias/index.js';
|
||||||
import markdownVitePlugin from '../vite-plugin-markdown/index.js';
|
import markdownVitePlugin from '../vite-plugin-markdown/index.js';
|
||||||
import jsxVitePlugin from '../vite-plugin-jsx/index.js';
|
import jsxVitePlugin from '../vite-plugin-jsx/index.js';
|
||||||
|
import envVitePlugin from '../vite-plugin-env/index.js';
|
||||||
import { resolveDependency } from './util.js';
|
import { resolveDependency } from './util.js';
|
||||||
|
|
||||||
// Some packages are just external, and that’s the way it goes.
|
// 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 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.
|
// the build to run very slow as the filewatcher is triggered often.
|
||||||
mode === 'dev' && astroViteServerPlugin({ config: astroConfig, logging }),
|
mode === 'dev' && astroViteServerPlugin({ config: astroConfig, logging }),
|
||||||
|
envVitePlugin({ config: astroConfig }),
|
||||||
markdownVitePlugin({ config: astroConfig }),
|
markdownVitePlugin({ config: astroConfig }),
|
||||||
jsxVitePlugin({ config: astroConfig, logging }),
|
jsxVitePlugin({ config: astroConfig, logging }),
|
||||||
astroPostprocessVitePlugin({ config: astroConfig }),
|
astroPostprocessVitePlugin({ config: astroConfig }),
|
||||||
|
|
11
packages/astro/src/vite-plugin-env/README.md
Normal file
11
packages/astro/src/vite-plugin-env/README.md
Normal file
|
@ -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
|
||||||
|
```
|
83
packages/astro/src/vite-plugin-env/index.ts
Normal file
83
packages/astro/src/vite-plugin-env/index.ts
Normal file
|
@ -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<string, any>) {
|
||||||
|
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<string, any> | 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -14,10 +14,10 @@ describe('Environment Variables', () => {
|
||||||
expect(true).to.equal(true);
|
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');
|
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');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
PUBLIC_PLACE: import.meta.env.PUBLIC_PLACE,
|
PUBLIC_PLACE: import.meta.env.PUBLIC_PLACE,
|
||||||
|
SECRET_PLACE: import.meta.env.SECRET_PLACE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,4 +3,4 @@ import Client from '../components/Client.vue';
|
||||||
---
|
---
|
||||||
<environment-variable>{import.meta.env.PUBLIC_PLACE}</environment-variable>
|
<environment-variable>{import.meta.env.PUBLIC_PLACE}</environment-variable>
|
||||||
<environment-variable>{import.meta.env.SECRET_PLACE}</environment-variable>
|
<environment-variable>{import.meta.env.SECRET_PLACE}</environment-variable>
|
||||||
<Client client:load />
|
<Client client:load />
|
||||||
|
|
|
@ -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 the trailing average is higher than the median, see if it's more than 5% higher
|
||||||
if (averageOfLastThirty > medianOfAll) {
|
if (averageOfLastThirty > medianOfAll) {
|
||||||
let percentage = Math.abs(averageOfLastThirty - medianOfAll) / medianOfAll;
|
let percentage = Math.abs(averageOfLastThirty - medianOfAll) / medianOfAll;
|
||||||
if (percentage > 0.05) {
|
if (percentage > 0.1) {
|
||||||
throw new Error(
|
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
|
medianOfAll
|
||||||
)}). This tells us that memory continues to grow and a leak is likely.`
|
)}). This tells us that memory continues to grow and a leak is likely.`
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in a new issue