Support environment variables in Cloudflare and Netlify Edge functions (#5301)

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Bjorn Lu 2022-11-08 21:54:49 +08:00 committed by GitHub
parent 7f3b0398e0
commit a79a37cad5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 132 additions and 96 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Improve environment variable handling performance

View file

@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---
Fix environment variables usage in worker output and warn if environment variables are accessedd too early

View file

@ -0,0 +1,5 @@
---
'@astrojs/netlify': patch
---
Fix environment variables usage in edge functions

View file

@ -8,41 +8,44 @@ interface EnvPluginOptions {
settings: AstroSettings; settings: AstroSettings;
} }
function getPrivateEnv(viteConfig: vite.ResolvedConfig, astroConfig: AstroConfig) { function getPrivateEnv(
viteConfig: vite.ResolvedConfig,
astroConfig: AstroConfig
): Record<string, string> {
let envPrefixes: string[] = ['PUBLIC_']; let envPrefixes: string[] = ['PUBLIC_'];
if (viteConfig.envPrefix) { if (viteConfig.envPrefix) {
envPrefixes = Array.isArray(viteConfig.envPrefix) envPrefixes = Array.isArray(viteConfig.envPrefix)
? viteConfig.envPrefix ? viteConfig.envPrefix
: [viteConfig.envPrefix]; : [viteConfig.envPrefix];
} }
// Loads environment variables from `.env` files and `process.env`
const fullEnv = loadEnv( const fullEnv = loadEnv(
viteConfig.mode, viteConfig.mode,
viteConfig.envDir ?? fileURLToPath(astroConfig.root), viteConfig.envDir ?? fileURLToPath(astroConfig.root),
'' ''
); );
const privateKeys = Object.keys(fullEnv).filter((key) => {
// 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 const privateEnv: Record<string, string> = {};
return true; for (const key in fullEnv) {
}); // Ignore public env var
if (privateKeys.length === 0) { if (envPrefixes.every((prefix) => !key.startsWith(prefix))) {
return null; if (typeof process.env[key] !== 'undefined') {
privateEnv[key] = `process.env.${key}`;
} else {
privateEnv[key] = JSON.stringify(fullEnv[key]);
} }
return Object.fromEntries( }
privateKeys.map((key) => { }
if (typeof process.env[key] !== 'undefined') return [key, `process.env.${key}`]; privateEnv.SITE = astroConfig.site ? `'${astroConfig.site}'` : 'undefined';
return [key, JSON.stringify(fullEnv[key])]; privateEnv.SSR = JSON.stringify(true);
}) privateEnv.BASE_URL = astroConfig.base ? `'${astroConfig.base}'` : 'undefined';
); return privateEnv;
} }
function getReferencedPrivateKeys(source: string, privateEnv: Record<string, any>): Set<string> { function getReferencedPrivateKeys(source: string, privateEnv: Record<string, any>): Set<string> {
const references = new Set<string>(); const references = new Set<string>();
for (const key of Object.keys(privateEnv)) { for (const key in privateEnv) {
if (source.includes(key)) { if (source.includes(key)) {
references.add(key); references.add(key);
} }
@ -51,91 +54,70 @@ function getReferencedPrivateKeys(source: string, privateEnv: Record<string, any
} }
export default function envVitePlugin({ settings }: EnvPluginOptions): vite.PluginOption { export default function envVitePlugin({ settings }: EnvPluginOptions): vite.PluginOption {
let privateEnv: Record<string, any> | null; let privateEnv: Record<string, string>;
let config: vite.ResolvedConfig; let viteConfig: vite.ResolvedConfig;
let replacements: Record<string, string>;
let pattern: RegExp | undefined;
const { config: astroConfig } = settings; const { config: astroConfig } = settings;
return { return {
name: 'astro:vite-plugin-env', name: 'astro:vite-plugin-env',
enforce: 'pre', enforce: 'pre',
configResolved(resolvedConfig) { configResolved(resolvedConfig) {
config = resolvedConfig; viteConfig = resolvedConfig;
}, },
async transform(source, id, options) { async transform(source, id, options) {
const ssr = options?.ssr === true; if (!options?.ssr || !source.includes('import.meta.env')) {
if (!ssr) {
return; return;
} }
if (!source.includes('import.meta') || !/\benv\b/.test(source)) { // Find matches for *private* env and do our own replacement.
return; let s: MagicString | undefined;
} const pattern = new RegExp(
// Do not allow preceding '.', but do allow preceding '...' for spread operations
if (typeof privateEnv === 'undefined') { '(?<!(?<!\\.\\.)\\.)\\b(' +
privateEnv = getPrivateEnv(config, astroConfig); // Captures `import.meta.env.*` calls and replace with `privateEnv`
if (privateEnv) { `import\\.meta\\.env\\.(.+?)` +
privateEnv.SITE = astroConfig.site ? `'${astroConfig.site}'` : 'undefined'; '|' +
privateEnv.SSR = JSON.stringify(true);
privateEnv.BASE_URL = astroConfig.base ? `'${astroConfig.base}'` : undefined;
const entries = Object.entries(privateEnv).map(([key, value]) => [
`import.meta.env.${key}`,
value,
]);
replacements = Object.fromEntries(entries);
// These additional replacements are needed to match Vite
replacements = Object.assign(replacements, {
'import.meta.env.SITE': astroConfig.site ? `'${astroConfig.site}'` : 'undefined',
'import.meta.env.SSR': JSON.stringify(true),
'import.meta.env.BASE_URL': astroConfig.base ? `'${astroConfig.base}'` : undefined,
// This catches destructed `import.meta.env` calls, // This catches destructed `import.meta.env` calls,
// BUT we only want to inject private keys referenced in the file. // BUT we only want to inject private keys referenced in the file.
// We overwrite this value on a per-file basis. // We overwrite this value on a per-file basis.
'import.meta.env': `({})`, 'import\\.meta\\.env' +
});
pattern = new RegExp(
// Do not allow preceding '.', but do allow preceding '...' for spread operations
'(?<!(?<!\\.\\.)\\.)\\b(' +
Object.keys(replacements)
.map((str) => {
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
})
.join('|') +
// prevent trailing assignments // prevent trailing assignments
')\\b(?!\\s*?=[^=])', ')\\b(?!\\s*?=[^=])',
'g' 'g'
); );
} let references: Set<string>;
}
if (!privateEnv || !pattern) return;
const references = getReferencedPrivateKeys(source, privateEnv);
if (references.size === 0) return;
// Find matches for *private* env and do our own replacement.
const s = new MagicString(source);
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = pattern.exec(source))) { while ((match = pattern.exec(source))) {
const start = match.index; let replacement: string | undefined;
const end = start + match[0].length;
let replacement = '' + replacements[match[1]];
// If we match exactly `import.meta.env`, define _only_ referenced private variables // If we match exactly `import.meta.env`, define _only_ referenced private variables
if (match[0] === 'import.meta.env') { if (match[0] === 'import.meta.env') {
privateEnv ??= getPrivateEnv(viteConfig, astroConfig);
references ??= getReferencedPrivateKeys(source, privateEnv);
replacement = `(Object.assign(import.meta.env,{`; replacement = `(Object.assign(import.meta.env,{`;
for (const key of references.values()) { for (const key of references.values()) {
replacement += `${key}:${privateEnv[key]},`; replacement += `${key}:${privateEnv[key]},`;
} }
replacement += '}))'; replacement += '}))';
} }
// If we match `import.meta.env.*`, replace with private env
else if (match[2]) {
privateEnv ??= getPrivateEnv(viteConfig, astroConfig);
replacement = privateEnv[match[2]];
}
if (replacement) {
const start = match.index;
const end = start + match[0].length;
s ??= new MagicString(source);
s.overwrite(start, end, replacement); s.overwrite(start, end, replacement);
} }
}
if (s) {
return { return {
code: s.toString(), code: s.toString(),
map: s.generateMap(), map: s.generateMap(),
}; };
}
}, },
}; };
} }

View file

@ -92,16 +92,18 @@ To do this:
## Environment Variables ## Environment Variables
As Cloudflare Pages Functions [provides environment variables differently](https://developers.cloudflare.com/pages/platform/functions/#adding-environment-variables-locally), private environment variables needs to be set through [`vite.define`](https://vitejs.dev/config/shared-options.html#define) to work in builds. As Cloudflare Pages Functions [provides environment variables per request](https://developers.cloudflare.com/pages/platform/functions/#adding-environment-variables-locally), you can only access private environment variables when a request has happened. Usually, this means moving environment variable access inside a function.
```js ```js
// astro.config.mjs // pages/[id].json.js
export default {
vite: { export function get({ params }) {
define: { // Access environment variables per request inside a function
'process.env.MY_SECRET': JSON.stringify(process.env.MY_SECRET), const serverUrl = import.meta.env.SERVER_URL;
}, const result = await fetch(serverUrl + "/user/" + params.id);
}, return {
body: await result.text(),
};
} }
``` ```

View file

@ -1,7 +1,8 @@
import './shim.js';
import type { SSRManifest } from 'astro'; import type { SSRManifest } from 'astro';
import { App } from 'astro/app'; import { App } from 'astro/app';
import { getProcessEnvProxy } from './util.js';
process.env = getProcessEnvProxy();
type Env = { type Env = {
ASSETS: { fetch: (req: Request) => Promise<Response> }; ASSETS: { fetch: (req: Request) => Promise<Response> };
@ -12,6 +13,8 @@ export function createExports(manifest: SSRManifest) {
const app = new App(manifest, false); const app = new App(manifest, false);
const fetch = async (request: Request, env: Env, context: any) => { const fetch = async (request: Request, env: Env, context: any) => {
process.env = env as any;
const { origin, pathname } = new URL(request.url); const { origin, pathname } = new URL(request.url);
// static assets // static assets

View file

@ -1,7 +1,8 @@
import './shim.js';
import type { SSRManifest } from 'astro'; import type { SSRManifest } from 'astro';
import { App } from 'astro/app'; import { App } from 'astro/app';
import { getProcessEnvProxy } from './util.js';
process.env = getProcessEnvProxy();
export function createExports(manifest: SSRManifest) { export function createExports(manifest: SSRManifest) {
const app = new App(manifest, false); const app = new App(manifest, false);
@ -14,6 +15,8 @@ export function createExports(manifest: SSRManifest) {
request: Request; request: Request;
next: (request: Request) => void; next: (request: Request) => void;
} & Record<string, unknown>) => { } & Record<string, unknown>) => {
process.env = runtimeEnv.env as any;
const { origin, pathname } = new URL(request.url); const { origin, pathname } = new URL(request.url);
// static assets // static assets
if (manifest.assets.has(pathname)) { if (manifest.assets.has(pathname)) {

View file

@ -1,4 +0,0 @@
(globalThis as any).process = {
argv: [],
env: {},
};

View file

@ -0,0 +1,16 @@
export function getProcessEnvProxy() {
return new Proxy(
{},
{
get: (target, prop) => {
console.warn(
// NOTE: \0 prevents Vite replacement
`Unable to access \`import.meta\0.env.${prop.toString()}\` on initialization ` +
`as the Cloudflare platform only provides the environment variables per request. ` +
`Please move the environment variable access inside a function ` +
`that's only called after a request has been received.`
);
},
}
);
}

View file

@ -24,6 +24,7 @@ describe.skip('Basic app', () => {
let html = await res.text(); let html = await res.text();
let $ = cheerio.load(html); let $ = cheerio.load(html);
expect($('h1').text()).to.equal('Testing'); expect($('h1').text()).to.equal('Testing');
expect($('#env').text()).to.equal('secret');
} finally { } finally {
stop(); stop();
} }

View file

@ -1,6 +1,9 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare'; import cloudflare from '@astrojs/cloudflare';
// test env var
process.env.SECRET_STUFF = 'secret'
export default defineConfig({ export default defineConfig({
adapter: cloudflare(), adapter: cloudflare(),
output: 'server', output: 'server',

View file

@ -4,5 +4,6 @@
</head> </head>
<body> <body>
<h1>Testing</h1> <h1>Testing</h1>
<div id="env">{import.meta.env.SECRET_STUFF}</div>
</body> </body>
</html> </html>

View file

@ -0,0 +1,4 @@
# for tests only
[vars]
SECRET_STUFF = "secret"

View file

@ -14,7 +14,7 @@ interface BuildConfig {
const SHIM = `globalThis.process = { const SHIM = `globalThis.process = {
argv: [], argv: [],
env: {}, env: Deno.env.toObject(),
};`; };`;
export function getAdapter(): AstroAdapter { export function getAdapter(): AstroAdapter {

View file

@ -3,6 +3,9 @@ import { runBuild } from './test-utils.ts';
// @ts-ignore // @ts-ignore
import { assertEquals, assert, DOMParser } from './deps.ts'; import { assertEquals, assert, DOMParser } from './deps.ts';
// @ts-ignore
Deno.env.set('SECRET_STUFF', 'secret');
// @ts-ignore // @ts-ignore
Deno.test({ Deno.test({
// TODO: debug why build cannot be found in "await import" // TODO: debug why build cannot be found in "await import"
@ -23,6 +26,9 @@ Deno.test({
const div = doc.querySelector('#react'); const div = doc.querySelector('#react');
assert(div, 'div exists'); assert(div, 'div exists');
const envDiv = doc.querySelector('#env');
assertEquals(envDiv?.innerText, 'secret');
await close(); await close();
}, },
}); });

View file

@ -2,6 +2,9 @@ import { defineConfig } from 'astro/config';
import { netlifyEdgeFunctions } from '@astrojs/netlify'; import { netlifyEdgeFunctions } from '@astrojs/netlify';
import react from "@astrojs/react"; import react from "@astrojs/react";
// test env var
process.env.SECRET_STUFF = 'secret'
export default defineConfig({ export default defineConfig({
adapter: netlifyEdgeFunctions({ adapter: netlifyEdgeFunctions({
dist: new URL('./dist/', import.meta.url), dist: new URL('./dist/', import.meta.url),

View file

@ -10,5 +10,6 @@ import ReactComponent from '../components/React.jsx';
<li><a href="/two/">Two</a></li> <li><a href="/two/">Two</a></li>
</ul> </ul>
<ReactComponent /> <ReactComponent />
<div id="env">{import.meta.env.SECRET_STUFF}</div>
</body> </body>
</html> </html>