Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support environment variables in Cloudflare and Netlify Edge functions #5301

Merged
merged 24 commits into from
Nov 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cold-needles-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Improve environment variable handling performance
5 changes: 5 additions & 0 deletions .changeset/hungry-coats-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---

Fix environment variables usage in worker output and warn if environment variables are accessedd too early
5 changes: 5 additions & 0 deletions .changeset/orange-melons-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/netlify': patch
---

Fix environment variables usage in edge functions
140 changes: 61 additions & 79 deletions packages/astro/src/vite-plugin-env/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,44 @@ interface EnvPluginOptions {
settings: AstroSettings;
}

function getPrivateEnv(viteConfig: vite.ResolvedConfig, astroConfig: AstroConfig) {
function getPrivateEnv(
viteConfig: vite.ResolvedConfig,
astroConfig: AstroConfig
): Record<string, string> {
let envPrefixes: string[] = ['PUBLIC_'];
if (viteConfig.envPrefix) {
envPrefixes = Array.isArray(viteConfig.envPrefix)
? viteConfig.envPrefix
: [viteConfig.envPrefix];
}

// Loads environment variables from `.env` files and `process.env`
const fullEnv = loadEnv(
viteConfig.mode,
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
return true;
});
if (privateKeys.length === 0) {
return null;
const privateEnv: Record<string, string> = {};
for (const key in fullEnv) {
// Ignore public env var
if (envPrefixes.every((prefix) => !key.startsWith(prefix))) {
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}`];
return [key, JSON.stringify(fullEnv[key])];
})
);
privateEnv.SITE = astroConfig.site ? `'${astroConfig.site}'` : 'undefined';
privateEnv.SSR = JSON.stringify(true);
privateEnv.BASE_URL = astroConfig.base ? `'${astroConfig.base}'` : 'undefined';
Comment on lines +40 to +42
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Greatly appreciate these refactors! I knew this plugin was messy and never had time to come back and clean it up. 🎉

return privateEnv;
}

function getReferencedPrivateKeys(source: string, privateEnv: Record<string, any>): Set<string> {
const references = new Set<string>();
for (const key of Object.keys(privateEnv)) {
for (const key in privateEnv) {
if (source.includes(key)) {
references.add(key);
}
Expand All @@ -51,91 +54,70 @@ function getReferencedPrivateKeys(source: string, privateEnv: Record<string, any
}

export default function envVitePlugin({ settings }: EnvPluginOptions): vite.PluginOption {
let privateEnv: Record<string, any> | null;
let config: vite.ResolvedConfig;
let replacements: Record<string, string>;
let pattern: RegExp | undefined;
let privateEnv: Record<string, string>;
let viteConfig: vite.ResolvedConfig;
const { config: astroConfig } = settings;
return {
name: 'astro:vite-plugin-env',
enforce: 'pre',
configResolved(resolvedConfig) {
config = resolvedConfig;
viteConfig = resolvedConfig;
},
async transform(source, id, options) {
const ssr = options?.ssr === true;

if (!ssr) {
return;
}

if (!source.includes('import.meta') || !/\benv\b/.test(source)) {
if (!options?.ssr || !source.includes('import.meta.env')) {
return;
}

if (typeof privateEnv === 'undefined') {
privateEnv = getPrivateEnv(config, astroConfig);
if (privateEnv) {
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,
// BUT we only want to inject private keys referenced in the file.
// We overwrite this value on a per-file basis.
'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
')\\b(?!\\s*?=[^=])',
'g'
);
}
}

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 s: MagicString | undefined;
const pattern = new RegExp(
// Do not allow preceding '.', but do allow preceding '...' for spread operations
'(?<!(?<!\\.\\.)\\.)\\b(' +
// Captures `import.meta.env.*` calls and replace with `privateEnv`
`import\\.meta\\.env\\.(.+?)` +
'|' +
// This catches destructed `import.meta.env` calls,
// BUT we only want to inject private keys referenced in the file.
// We overwrite this value on a per-file basis.
'import\\.meta\\.env' +
// prevent trailing assignments
')\\b(?!\\s*?=[^=])',
'g'
);
let references: Set<string>;
let match: RegExpExecArray | null;

while ((match = pattern.exec(source))) {
const start = match.index;
const end = start + match[0].length;
let replacement = '' + replacements[match[1]];
let replacement: string | undefined;
// If we match exactly `import.meta.env`, define _only_ referenced private variables
if (match[0] === 'import.meta.env') {
privateEnv ??= getPrivateEnv(viteConfig, astroConfig);
bluwy marked this conversation as resolved.
Show resolved Hide resolved
references ??= getReferencedPrivateKeys(source, privateEnv);
replacement = `(Object.assign(import.meta.env,{`;
for (const key of references.values()) {
replacement += `${key}:${privateEnv[key]},`;
}
replacement += '}))';
}
s.overwrite(start, end, 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);
}
}

return {
code: s.toString(),
map: s.generateMap(),
};
if (s) {
return {
code: s.toString(),
map: s.generateMap(),
};
}
},
};
}
18 changes: 10 additions & 8 deletions packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,18 @@ To do this:

## 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
// astro.config.mjs
export default {
vite: {
define: {
'process.env.MY_SECRET': JSON.stringify(process.env.MY_SECRET),
},
},
// pages/[id].json.js

export function get({ params }) {
// Access environment variables per request inside a function
const serverUrl = import.meta.env.SERVER_URL;
const result = await fetch(serverUrl + "/user/" + params.id);
return {
body: await result.text(),
};
}
```

Expand Down
7 changes: 5 additions & 2 deletions packages/integrations/cloudflare/src/server.advanced.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import './shim.js';

import type { SSRManifest } from 'astro';
import { App } from 'astro/app';
import { getProcessEnvProxy } from './util.js';

process.env = getProcessEnvProxy();

type Env = {
ASSETS: { fetch: (req: Request) => Promise<Response> };
Expand All @@ -12,6 +13,8 @@ export function createExports(manifest: SSRManifest) {
const app = new App(manifest, false);

const fetch = async (request: Request, env: Env, context: any) => {
process.env = env as any;

const { origin, pathname } = new URL(request.url);

// static assets
Expand Down
7 changes: 5 additions & 2 deletions packages/integrations/cloudflare/src/server.directory.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import './shim.js';

import type { SSRManifest } from 'astro';
import { App } from 'astro/app';
import { getProcessEnvProxy } from './util.js';

process.env = getProcessEnvProxy();

export function createExports(manifest: SSRManifest) {
const app = new App(manifest, false);
Expand All @@ -14,6 +15,8 @@ export function createExports(manifest: SSRManifest) {
request: Request;
next: (request: Request) => void;
} & Record<string, unknown>) => {
process.env = runtimeEnv.env as any;

const { origin, pathname } = new URL(request.url);
// static assets
if (manifest.assets.has(pathname)) {
Expand Down
4 changes: 0 additions & 4 deletions packages/integrations/cloudflare/src/shim.ts

This file was deleted.

16 changes: 16 additions & 0 deletions packages/integrations/cloudflare/src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function getProcessEnvProxy() {
return new Proxy(
{},
{
get: (target, prop) => {
console.warn(
// NOTE: \0 prevents Vite replacement
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WHAT! I totally missed this. That's very smart.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's slightly leaning towards a hack that should be fixed in Vite 4 (hopefully) 😬

`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.`
);
},
}
);
}
1 change: 1 addition & 0 deletions packages/integrations/cloudflare/test/basics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe.skip('Basic app', () => {
let html = await res.text();
let $ = cheerio.load(html);
expect($('h1').text()).to.equal('Testing');
expect($('#env').text()).to.equal('secret');
} finally {
stop();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

// test env var
process.env.SECRET_STUFF = 'secret'

export default defineConfig({
adapter: cloudflare(),
output: 'server',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
</head>
<body>
<h1>Testing</h1>
<div id="env">{import.meta.env.SECRET_STUFF}</div>
</body>
</html>
4 changes: 4 additions & 0 deletions packages/integrations/cloudflare/test/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# for tests only

[vars]
SECRET_STUFF = "secret"
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface BuildConfig {

const SHIM = `globalThis.process = {
argv: [],
env: {},
env: Deno.env.toObject(),
};`;

export function getAdapter(): AstroAdapter {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { runBuild } from './test-utils.ts';
// @ts-ignore
import { assertEquals, assert, DOMParser } from './deps.ts';

// @ts-ignore
Deno.env.set('SECRET_STUFF', 'secret');

// @ts-ignore
Deno.test({
// TODO: debug why build cannot be found in "await import"
Expand All @@ -23,6 +26,9 @@ Deno.test({
const div = doc.querySelector('#react');
assert(div, 'div exists');

const envDiv = doc.querySelector('#env');
assertEquals(envDiv?.innerText, 'secret');

await close();
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { defineConfig } from 'astro/config';
import { netlifyEdgeFunctions } from '@astrojs/netlify';
import react from "@astrojs/react";

// test env var
process.env.SECRET_STUFF = 'secret'

export default defineConfig({
adapter: netlifyEdgeFunctions({
dist: new URL('./dist/', import.meta.url),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ import ReactComponent from '../components/React.jsx';
<li><a href="/two/">Two</a></li>
</ul>
<ReactComponent />
<div id="env">{import.meta.env.SECRET_STUFF}</div>
</body>
</html>