Skip to content

Commit e72efd6

Browse files
authoredJan 11, 2024
Use esbuild for env replacement (#9652)
1 parent 50f3918 commit e72efd6

File tree

11 files changed

+157
-76
lines changed

11 files changed

+157
-76
lines changed
 

‎.changeset/lazy-pandas-pretend.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@astrojs/mdx": patch
3+
---
4+
5+
Removes environment variables workaround that broke project builds with sourcemaps

‎.changeset/silent-pandas-rush.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"astro": patch
3+
---
4+
5+
Improves environment variables handling by using esbuild to perform replacements

‎packages/astro/src/content/vite-plugin-content-imports.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { getProxyCode } from '../assets/utils/proxy.js';
1616
import { AstroError } from '../core/errors/errors.js';
1717
import { AstroErrorData } from '../core/errors/index.js';
1818
import { isServerLikeOutput } from '../prerender/utils.js';
19-
import { escapeViteEnvReferences } from '../vite-plugin-utils/index.js';
2019
import { CONTENT_FLAG, DATA_FLAG } from './consts.js';
2120
import {
2221
getContentEntryExts,
@@ -93,7 +92,7 @@ export function astroContentImportPlugin({
9392
pluginContext: this,
9493
});
9594

96-
const code = escapeViteEnvReferences(`
95+
const code = `
9796
export const id = ${JSON.stringify(id)};
9897
export const collection = ${JSON.stringify(collection)};
9998
export const data = ${stringifyEntryData(data, isServerLikeOutput(settings.config))};
@@ -102,7 +101,7 @@ export const _internal = {
102101
filePath: ${JSON.stringify(_internal.filePath)},
103102
rawData: ${JSON.stringify(_internal.rawData)},
104103
};
105-
`);
104+
`;
106105
return code;
107106
} else if (hasContentFlag(viteId, CONTENT_FLAG)) {
108107
const fileId = viteId.split('?')[0];
@@ -115,7 +114,7 @@ export const _internal = {
115114
pluginContext: this,
116115
});
117116

118-
const code = escapeViteEnvReferences(`
117+
const code = `
119118
export const id = ${JSON.stringify(id)};
120119
export const collection = ${JSON.stringify(collection)};
121120
export const slug = ${JSON.stringify(slug)};
@@ -125,7 +124,7 @@ export const _internal = {
125124
type: 'content',
126125
filePath: ${JSON.stringify(_internal.filePath)},
127126
rawData: ${JSON.stringify(_internal.rawData)},
128-
};`);
127+
};`;
129128

130129
return { code, map: { mappings: '' } };
131130
}
+119-47
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
import MagicString from 'magic-string';
21
import { fileURLToPath } from 'node:url';
32
import type * as vite from 'vite';
43
import { loadEnv } from 'vite';
4+
import { transform } from 'esbuild';
5+
import MagicString from 'magic-string';
56
import type { AstroConfig, AstroSettings } from '../@types/astro.js';
67

78
interface EnvPluginOptions {
89
settings: AstroSettings;
910
}
1011

12+
// Match `import.meta.env` directly without trailing property access
13+
const importMetaEnvOnlyRe = /\bimport\.meta\.env\b(?!\.)/;
14+
// Match valid JS variable names (identifiers), which accepts most alphanumeric characters,
15+
// except that the first character cannot be a number.
16+
const isValidIdentifierRe = /^[_$a-zA-Z][_$a-zA-Z0-9]*$/;
17+
1118
function getPrivateEnv(
1219
viteConfig: vite.ResolvedConfig,
1320
astroConfig: AstroConfig
@@ -29,7 +36,7 @@ function getPrivateEnv(
2936
const privateEnv: Record<string, string> = {};
3037
for (const key in fullEnv) {
3138
// Ignore public env var
32-
if (envPrefixes.every((prefix) => !key.startsWith(prefix))) {
39+
if (isValidIdentifierRe.test(key) && envPrefixes.every((prefix) => !key.startsWith(prefix))) {
3340
if (typeof process.env[key] !== 'undefined') {
3441
let value = process.env[key];
3542
// Replacements are always strings, so try to convert to strings here first
@@ -61,71 +68,136 @@ function getReferencedPrivateKeys(source: string, privateEnv: Record<string, any
6168
return references;
6269
}
6370

64-
export default function envVitePlugin({ settings }: EnvPluginOptions): vite.PluginOption {
71+
/**
72+
* Use esbuild to perform replacememts like Vite
73+
* https://github.com/vitejs/vite/blob/5ea9edbc9ceb991e85f893fe62d68ed028677451/packages/vite/src/node/plugins/define.ts#L130
74+
*/
75+
async function replaceDefine(
76+
code: string,
77+
id: string,
78+
define: Record<string, string>,
79+
config: vite.ResolvedConfig
80+
): Promise<{ code: string; map: string | null }> {
81+
// Since esbuild doesn't support replacing complex expressions, we replace `import.meta.env`
82+
// with a marker string first, then postprocess and apply the `Object.assign` code.
83+
const replacementMarkers: Record<string, string> = {};
84+
const env = define['import.meta.env'];
85+
if (env) {
86+
// Compute the marker from the length of the replaced code. We do this so that esbuild generates
87+
// the sourcemap with the right column offset when we do the postprocessing.
88+
const marker = `__astro_import_meta_env${'_'.repeat(
89+
env.length - 23 /* length of preceding string */
90+
)}`;
91+
replacementMarkers[marker] = env;
92+
define = { ...define, 'import.meta.env': marker };
93+
}
94+
95+
const esbuildOptions = config.esbuild || {};
96+
97+
const result = await transform(code, {
98+
loader: 'js',
99+
charset: esbuildOptions.charset ?? 'utf8',
100+
platform: 'neutral',
101+
define,
102+
sourcefile: id,
103+
sourcemap: config.command === 'build' ? !!config.build.sourcemap : true,
104+
});
105+
106+
for (const marker in replacementMarkers) {
107+
result.code = result.code.replaceAll(marker, replacementMarkers[marker]);
108+
}
109+
110+
return {
111+
code: result.code,
112+
map: result.map || null,
113+
};
114+
}
115+
116+
export default function envVitePlugin({ settings }: EnvPluginOptions): vite.Plugin {
65117
let privateEnv: Record<string, string>;
118+
let defaultDefines: Record<string, string>;
119+
let isDev: boolean;
120+
let devImportMetaEnvPrepend: string;
66121
let viteConfig: vite.ResolvedConfig;
67122
const { config: astroConfig } = settings;
68123
return {
69124
name: 'astro:vite-plugin-env',
70-
enforce: 'pre',
125+
config(_, { command }) {
126+
isDev = command !== 'build';
127+
},
71128
configResolved(resolvedConfig) {
72129
viteConfig = resolvedConfig;
130+
131+
// HACK: move ourselves before Vite's define plugin to apply replacements at the right time (before Vite normal plugins)
132+
const viteDefinePluginIndex = resolvedConfig.plugins.findIndex(
133+
(p) => p.name === 'vite:define'
134+
);
135+
if (viteDefinePluginIndex !== -1) {
136+
const myPluginIndex = resolvedConfig.plugins.findIndex(
137+
(p) => p.name === 'astro:vite-plugin-env'
138+
);
139+
if (myPluginIndex !== -1) {
140+
const myPlugin = resolvedConfig.plugins[myPluginIndex];
141+
// @ts-ignore-error ignore readonly annotation
142+
resolvedConfig.plugins.splice(viteDefinePluginIndex, 0, myPlugin);
143+
// @ts-ignore-error ignore readonly annotation
144+
resolvedConfig.plugins.splice(myPluginIndex, 1);
145+
}
146+
}
73147
},
74-
async transform(source, id, options) {
148+
transform(source, id, options) {
75149
if (!options?.ssr || !source.includes('import.meta.env')) {
76150
return;
77151
}
78152

79153
// Find matches for *private* env and do our own replacement.
80-
let s: MagicString | undefined;
81-
const pattern = new RegExp(
82-
// Do not allow preceding '.', but do allow preceding '...' for spread operations
83-
'(?<!(?<!\\.\\.)\\.)\\b(' +
84-
// Captures `import.meta.env.*` calls and replace with `privateEnv`
85-
`import\\.meta\\.env\\.(.+?)` +
86-
'|' +
87-
// This catches destructed `import.meta.env` calls,
88-
// BUT we only want to inject private keys referenced in the file.
89-
// We overwrite this value on a per-file basis.
90-
'import\\.meta\\.env' +
91-
// prevent trailing assignments
92-
')\\b(?!\\s*?=[^=])',
93-
'g'
94-
);
95-
let references: Set<string>;
96-
let match: RegExpExecArray | null;
97-
98-
while ((match = pattern.exec(source))) {
99-
let replacement: string | undefined;
100-
// If we match exactly `import.meta.env`, define _only_ referenced private variables
101-
if (match[0] === 'import.meta.env') {
102-
privateEnv ??= getPrivateEnv(viteConfig, astroConfig);
103-
references ??= getReferencedPrivateKeys(source, privateEnv);
104-
replacement = `(Object.assign(import.meta.env,{`;
105-
for (const key of references.values()) {
106-
replacement += `${key}:${privateEnv[key]},`;
154+
privateEnv ??= getPrivateEnv(viteConfig, astroConfig);
155+
156+
// In dev, we can assign the private env vars to `import.meta.env` directly for performance
157+
if (isDev) {
158+
const s = new MagicString(source);
159+
if (!devImportMetaEnvPrepend) {
160+
devImportMetaEnvPrepend = `Object.assign(import.meta.env,{`;
161+
for (const key in privateEnv) {
162+
devImportMetaEnvPrepend += `${key}:${privateEnv[key]},`;
107163
}
108-
replacement += '}))';
109-
}
110-
// If we match `import.meta.env.*`, replace with private env
111-
else if (match[2]) {
112-
privateEnv ??= getPrivateEnv(viteConfig, astroConfig);
113-
replacement = privateEnv[match[2]];
164+
devImportMetaEnvPrepend += '});';
114165
}
115-
if (replacement) {
116-
const start = match.index;
117-
const end = start + match[0].length;
118-
s ??= new MagicString(source);
119-
s.overwrite(start, end, replacement);
120-
}
121-
}
122-
123-
if (s) {
166+
s.prepend(devImportMetaEnvPrepend);
124167
return {
125168
code: s.toString(),
126169
map: s.generateMap({ hires: 'boundary' }),
127170
};
128171
}
172+
173+
// In build, use esbuild to perform replacements. Compute the default defines for esbuild here as a
174+
// separate object as it could be extended by `import.meta.env` later.
175+
if (!defaultDefines) {
176+
defaultDefines = {};
177+
for (const key in privateEnv) {
178+
defaultDefines[`import.meta.env.${key}`] = privateEnv[key];
179+
}
180+
}
181+
182+
let defines = defaultDefines;
183+
184+
// If reference the `import.meta.env` object directly, we want to inject private env vars
185+
// into Vite's injected `import.meta.env` object. To do this, we use `Object.assign` and keeping
186+
// the `import.meta.env` identifier so Vite sees it.
187+
if (importMetaEnvOnlyRe.test(source)) {
188+
const references = getReferencedPrivateKeys(source, privateEnv);
189+
let replacement = `(Object.assign(import.meta.env,{`;
190+
for (const key of references.values()) {
191+
replacement += `${key}:${privateEnv[key]},`;
192+
}
193+
replacement += '}))';
194+
defines = {
195+
...defaultDefines,
196+
'import.meta.env': replacement,
197+
};
198+
}
199+
200+
return replaceDefine(source, id, defines, viteConfig);
129201
},
130202
};
131203
}

‎packages/astro/src/vite-plugin-markdown/index.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { Logger } from '../core/logger/core.js';
1515
import { isMarkdownFile } from '../core/util.js';
1616
import { shorthash } from '../runtime/server/shorthash.js';
1717
import type { PluginMetadata } from '../vite-plugin-astro/types.js';
18-
import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js';
18+
import { getFileInfo } from '../vite-plugin-utils/index.js';
1919
import { getMarkdownCodeForImages, type MarkdownImagePath } from './images.js';
2020

2121
interface AstroPluginOptions {
@@ -116,7 +116,7 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
116116
);
117117
}
118118

119-
const code = escapeViteEnvReferences(`
119+
const code = `
120120
import { unescapeHTML, spreadAttributes, createComponent, render, renderComponent, maybeRenderHead } from ${JSON.stringify(
121121
astroServerRuntimeModulePath
122122
)};
@@ -166,7 +166,7 @@ export default function markdown({ settings, logger }: AstroPluginOptions): Plug
166166
}
167167
});
168168
export default Content;
169-
`);
169+
`;
170170

171171
return {
172172
code,

‎packages/astro/src/vite-plugin-utils/index.ts

-11
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,6 @@ import {
88
} from '../core/path.js';
99
import { viteID } from '../core/util.js';
1010

11-
/**
12-
* Converts the first dot in `import.meta.env` to its Unicode escape sequence,
13-
* which prevents Vite from replacing strings like `import.meta.env.SITE`
14-
* in our JS representation of modules like Markdown
15-
*/
16-
export function escapeViteEnvReferences(code: string) {
17-
return code
18-
.replace(/import\.meta\.env/g, 'import\\u002Emeta.env')
19-
.replace(/process\.env/g, 'process\\u002Eenv');
20-
}
21-
2211
export function getFileInfo(id: string, config: AstroConfig) {
2312
const sitePathname = appendForwardSlash(
2413
config.site ? new URL(config.base, config.site).pathname : config.base

‎packages/integrations/mdx/src/index.ts

+2-9
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
129129
const compiled = await processor.process(vfile);
130130

131131
return {
132-
code: escapeViteEnvReferences(String(compiled.value)),
132+
code: String(compiled.value),
133133
map: compiled.map,
134134
};
135135
} catch (e: any) {
@@ -215,7 +215,7 @@ export default function mdx(partialMdxOptions: Partial<MdxOptions> = {}): AstroI
215215
import.meta.hot.decline();
216216
}`;
217217
}
218-
return { code: escapeViteEnvReferences(code), map: null };
218+
return { code, map: null };
219219
},
220220
},
221221
] as VitePlugin[],
@@ -262,10 +262,3 @@ function applyDefaultOptions({
262262
optimize: options.optimize ?? defaults.optimize,
263263
};
264264
}
265-
266-
// Converts the first dot in `import.meta.env` to its Unicode escape sequence,
267-
// which prevents Vite from replacing strings like `import.meta.env.SITE`
268-
// in our JS representation of loaded Markdown files
269-
function escapeViteEnvReferences(code: string) {
270-
return code.replace(/import\.meta\.env/g, 'import\\u002Emeta.env');
271-
}

‎packages/integrations/mdx/src/recma-inject-import-meta-env.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function recmaInjectImportMetaEnv({
1111
if (node.type === 'MemberExpression') {
1212
// attempt to get "import.meta.env" variable name
1313
const envVarName = getImportMetaEnvVariableName(node);
14-
if (typeof envVarName === 'string') {
14+
if (typeof envVarName === 'string' && importMetaEnv[envVarName] != null) {
1515
// clear object keys to replace with envVarLiteral
1616
for (const key in node) {
1717
delete (node as any)[key];

‎packages/integrations/mdx/test/fixtures/mdx-vite-env-vars/astro.config.mjs

+7
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,11 @@ export default {
66
syntaxHighlight: false,
77
},
88
integrations: [mdx()],
9+
vite: {
10+
build: {
11+
// Enabling sourcemap may crash the build when using `import.meta.env.UNKNOWN_VAR`
12+
// https://github.com/withastro/astro/issues/9012
13+
sourcemap: true,
14+
},
15+
},
916
}

‎packages/integrations/mdx/test/fixtures/mdx-vite-env-vars/src/pages/vite-env-vars.mdx

+8
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ title: Let's talk about my import.meta.env.SITE
55
export const modeWorks =
66
import.meta.env.MODE === 'production' ? 'MODE works' : 'MODE does not work!';
77

8+
export const unknownVar = import.meta.env.UNKNOWN_VAR;
9+
810
# About my import.meta.env.SITE
911

1012
My `import.meta.env.SITE` is so cool, I can put env variables in code!
@@ -27,6 +29,12 @@ I can also use `import.meta.env` in variable exports: {modeWorks}
2729

2830
</div>
2931

32+
<div data-env-variable-exports-unknown>
33+
34+
I can also use `import.meta.env.UNKNOWN_VAR` through exports: "{unknownVar}"
35+
36+
</div>
37+
3038
I can also use vars as HTML attributes:
3139

3240
<div

‎packages/integrations/mdx/test/mdx-vite-env-vars.test.js

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ describe('MDX - Vite env vars', () => {
3838
expect(document.querySelector('[data-env-variable-exports]')?.innerHTML).to.contain(
3939
'MODE works'
4040
);
41+
expect(document.querySelector('[data-env-variable-exports-unknown]')?.innerHTML).to.contain(
42+
'exports: ””' // NOTE: these double quotes are special unicode quotes emitted in the HTML file
43+
);
4144
});
4245
it('Transforms `import.meta.env` in HTML attributes', async () => {
4346
const html = await fixture.readFile('/vite-env-vars/index.html');

0 commit comments

Comments
 (0)
Please sign in to comment.