Support environment variables in Cloudflare and Netlify Edge functions (#5301)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
7f3b0398e0
commit
a79a37cad5
17 changed files with 132 additions and 96 deletions
5
.changeset/cold-needles-heal.md
Normal file
5
.changeset/cold-needles-heal.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve environment variable handling performance
|
5
.changeset/hungry-coats-mix.md
Normal file
5
.changeset/hungry-coats-mix.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@astrojs/cloudflare': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix environment variables usage in worker output and warn if environment variables are accessedd too early
|
5
.changeset/orange-melons-roll.md
Normal file
5
.changeset/orange-melons-roll.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@astrojs/netlify': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix environment variables usage in edge functions
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
(globalThis as any).process = {
|
|
||||||
argv: [],
|
|
||||||
env: {},
|
|
||||||
};
|
|
16
packages/integrations/cloudflare/src/util.ts
Normal file
16
packages/integrations/cloudflare/src/util.ts
Normal 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.`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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>
|
||||||
|
|
4
packages/integrations/cloudflare/test/wrangler.toml
Normal file
4
packages/integrations/cloudflare/test/wrangler.toml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# for tests only
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
SECRET_STUFF = "secret"
|
|
@ -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 {
|
||||||
|
|
|
@ -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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue