diff --git a/packages/babel/src/index.ts b/packages/babel/src/index.ts index 42f920a6a..1482f1ea5 100644 --- a/packages/babel/src/index.ts +++ b/packages/babel/src/index.ts @@ -1,6 +1,6 @@ import { declare } from "@babel/helper-plugin-utils"; import type babelCore from "@babel/core"; -import { resolveEnv, accessor } from "../../shared"; +import { resolveEnv, createAccessor } from "../../shared"; import { resolveEnvExampleKeys } from "../../shared/resolve-env-example-keys"; import { PluginOptions } from "./types"; @@ -25,6 +25,8 @@ export default declare(({ template, types }, options) => { })() : Object.create(null); + const accessor = createAccessor(options.accessorKey); + const replaceEnvForCompileTime = ( template: typeof babelCore.template, property: string, diff --git a/packages/babel/src/types.ts b/packages/babel/src/types.ts index 153a2a67c..0d2d6b2b4 100644 --- a/packages/babel/src/types.ts +++ b/packages/babel/src/types.ts @@ -21,4 +21,15 @@ export interface PluginOptions { * process.env.NODE_ENV === "production" ? "runtime" : "compile-time" */ transformMode?: "compile-time" | "runtime"; + + /** + * The global variable key used to access environment variables at runtime. + * This determines the property name on `globalThis` where env vars are stored. + * + * @default "import_meta_env" + * @example + * With accessorKey: "my_env", the plugin transforms: + * import.meta.env.API_URL -> Object.create(globalThis.my_env || null).API_URL + */ + accessorKey?: string; } diff --git a/packages/cli/src/create-command.ts b/packages/cli/src/create-command.ts index 7b500964c..349a7d3cc 100644 --- a/packages/cli/src/create-command.ts +++ b/packages/cli/src/create-command.ts @@ -4,12 +4,16 @@ import colors from "picocolors"; import { version } from "../package.json"; import { resolveOutputFileNames } from "./resolve-output-file-names"; import { defaultOutput } from "./shared"; +import { DEFAULT_ACCESSOR_KEY } from "../../shared/constant"; export interface Args { env: string; example: string; path: string[]; disposable: boolean; + generate?: string; + prepend?: string; + accessorKey: string; } export const createCommand = () => @@ -44,6 +48,19 @@ export const createCommand = () => "--disposable", "Do not create backup files and restore from backup files. In local development, disable this option to avoid rebuilding the project when environment variable changes, In production, enable this option to avoid generating unnecessary backup files.", ) + .option( + "-g, --generate ", + "Generate a standalone JavaScript file containing environment variables instead of replacing placeholders in existing files.", + ) + .option( + "--prepend ", + "Prepend environment variables to an existing JavaScript file (e.g., remoteEntry.js). The globalThis. assignment will be inserted at the beginning of the file.", + ) + .option( + "-k, --accessor-key ", + "The global variable key used to access environment variables (e.g., globalThis.).", + DEFAULT_ACCESSOR_KEY, + ) .action((args: Args) => { args = { ...args }; @@ -60,6 +77,11 @@ export const createCommand = () => envExampleFilePath: args.example, }); + // Skip output file validation for --generate and --prepend modes + if (args.generate || args.prepend) { + return; + } + const path = args.path ?? defaultOutput; const foundOutputFilePaths = resolveOutputFileNames(path); if (foundOutputFilePaths.length === 0) { diff --git a/packages/cli/src/generate-env-file.ts b/packages/cli/src/generate-env-file.ts new file mode 100644 index 000000000..edcd7acf0 --- /dev/null +++ b/packages/cli/src/generate-env-file.ts @@ -0,0 +1,53 @@ +import { writeFileSync, readFileSync, mkdirSync, existsSync } from "fs"; +import { dirname } from "path"; +import serialize from "serialize-javascript"; +import { DEFAULT_ACCESSOR_KEY } from "../../shared/constant"; + +const generateEnvContent = ( + env: Record, + accessorKey: string +): string => { + return `globalThis.${accessorKey} = ${serialize(env)}; +`; +}; + +export const generateEnvFile = ({ + filePath, + env, + accessorKey = DEFAULT_ACCESSOR_KEY, +}: { + filePath: string; + env: Record; + accessorKey?: string; +}): void => { + // Ensure directory exists + const dir = dirname(filePath); + mkdirSync(dir, { recursive: true }); + + // Generate the JS content + const content = generateEnvContent(env, accessorKey); + + writeFileSync(filePath, content, "utf8"); +}; + +export const prependEnvToFile = ({ + filePath, + env, + accessorKey = DEFAULT_ACCESSOR_KEY, +}: { + filePath: string; + env: Record; + accessorKey?: string; +}): void => { + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const existingContent = readFileSync(filePath, "utf8"); + const envContent = generateEnvContent(env, accessorKey); + + // Prepend env content to existing file + const newContent = envContent + existingContent; + + writeFileSync(filePath, newContent, "utf8"); +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a98caf7de..5998f0ecf 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,6 +8,7 @@ import { backupFileExt, defaultOutput } from "./shared"; import { resolveOutputFileNames } from "./resolve-output-file-names"; import { replaceAllPlaceholderWithEnv } from "./replace-all-placeholder-with-env"; import { shouldInjectEnv } from "./should-inject-env"; +import { generateEnvFile, prependEnvToFile } from "./generate-env-file"; import colors from "picocolors"; export const main = (di: { @@ -22,6 +23,37 @@ export const main = (di: { envFilePath: opts.env, }); + // Generate mode: create a standalone env.js file + if (opts.generate) { + generateEnvFile({ + filePath: opts.generate, + env, + accessorKey: opts.accessorKey, + }); + console.info( + colors.green( + `[import-meta-env]: Generated ${opts.generate} with accessor key "${opts.accessorKey}"`, + ), + ); + return; + } + + // Prepend mode: prepend env vars to an existing JS file + if (opts.prepend) { + prependEnvToFile({ + filePath: opts.prepend, + env, + accessorKey: opts.accessorKey, + }); + console.info( + colors.green( + `[import-meta-env]: Prepended env vars to ${opts.prepend} with accessor key "${opts.accessorKey}"`, + ), + ); + return; + } + + // Legacy mode: replace placeholders in existing files const path = opts.path ?? defaultOutput; let hasReplaced = false; resolveOutputFileNames(path).forEach((outputFileName) => { diff --git a/packages/shared/constant.ts b/packages/shared/constant.ts index d63e57a5c..1882f6520 100644 --- a/packages/shared/constant.ts +++ b/packages/shared/constant.ts @@ -1,3 +1,10 @@ // 1. Accessor cannot contain `eval` as it would violate CSP. // 2. Accessor need to fallback to empty object, since during prerender there is no environment variables in globalThis. -export const accessor = `Object.create(globalThis.import_meta_env || null)`; + +export const DEFAULT_ACCESSOR_KEY = "import_meta_env"; + +export const createAccessor = (accessorKey = DEFAULT_ACCESSOR_KEY) => + `Object.create(globalThis.${accessorKey} || null)`; + +// Keep backward compatibility +export const accessor = createAccessor(); diff --git a/packages/unplugin/package.json b/packages/unplugin/package.json index a63f46b53..ebee07458 100644 --- a/packages/unplugin/package.json +++ b/packages/unplugin/package.json @@ -42,7 +42,7 @@ "rollup": "4.52.4", "ts-node": "10.9.2", "typescript": "5.9.3", - "vite": "6.3.6", + "vite": "6.4.0", "webpack": "5.102.1" }, "dependencies": { diff --git a/packages/unplugin/src/constant.ts b/packages/unplugin/src/constant.ts index c013359a7..d7d53e7f0 100644 --- a/packages/unplugin/src/constant.ts +++ b/packages/unplugin/src/constant.ts @@ -1,12 +1,13 @@ -import { accessor } from "../../shared/constant"; +import { createAccessor } from "../../shared/constant"; export const createAccessorRegExp = ( suffix: string, quote: "single" | "double" = "double", + accessorKey?: string, ) => new RegExp( "\\b" + - accessor + createAccessor(accessorKey) .replace(/\\/g, "\\\\") .replace(/([\(\)\[\]\|])/g, "\\$1") .replace(/\s/g, "\\s*") diff --git a/packages/unplugin/src/index.ts b/packages/unplugin/src/index.ts index b6d21368d..08dc04f03 100644 --- a/packages/unplugin/src/index.ts +++ b/packages/unplugin/src/index.ts @@ -92,7 +92,10 @@ export const unpluginFactory: UnpluginFactory = (options, meta) => { debug && console.debug(html); debug && console.debug("=================="); - html = html.replace(createAccessorRegExp(""), "import.meta.env"); + html = html.replace( + createAccessorRegExp("", "double", options?.accessorKey), + "import.meta.env", + ); debug && console.debug("=== index.html after ==="); debug && console.debug(html); @@ -206,6 +209,7 @@ export const unpluginFactory: UnpluginFactory = (options, meta) => { example: envExampleKeys, meta, viteConfig, + accessorKey: options?.accessorKey, }); debug && console.debug("=== after ==="); diff --git a/packages/unplugin/src/transform-prod.ts b/packages/unplugin/src/transform-prod.ts index d760bbf7a..dfe34e06e 100644 --- a/packages/unplugin/src/transform-prod.ts +++ b/packages/unplugin/src/transform-prod.ts @@ -6,7 +6,7 @@ import { } from "./vite/preserve-built-in-env"; import { unwrapSignalForImportMetaEnvEnv } from "./qwik/unwrap-signal-for-import-meta-env-env"; import MagicString from "magic-string"; -import { accessor } from "packages/shared"; +import { createAccessor } from "packages/shared"; import { replace } from "./replace"; export function transformProd({ @@ -15,13 +15,16 @@ export function transformProd({ meta, example, viteConfig, + accessorKey, }: { code: string; id: string; meta: UnpluginContextMeta; example: readonly string[]; viteConfig?: ViteResolvedConfig; + accessorKey?: string; }) { + const accessor = createAccessor(accessorKey); const s = new MagicString(code); if (id.includes("node_modules") === false) { diff --git a/packages/unplugin/src/types.ts b/packages/unplugin/src/types.ts index 11d005959..3cf3a0dab 100644 --- a/packages/unplugin/src/types.ts +++ b/packages/unplugin/src/types.ts @@ -29,4 +29,15 @@ export interface PluginOptions { * ``` */ transformMode?: "compile-time" | "runtime"; + + /** + * The global variable key used to access environment variables at runtime. + * This determines the property name on `globalThis` where env vars are stored. + * + * @default "import_meta_env" + * @example + * // With accessorKey: "my_env", the plugin transforms: + * // import.meta.env.API_URL -> Object.create(globalThis.my_env || null).API_URL + */ + accessorKey?: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c3e84146..ba83a63ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,8 +240,8 @@ importers: specifier: 5.9.3 version: 5.9.3 vite: - specifier: 6.3.6 - version: 6.3.6(@types/node@22.18.10)(terser@5.31.2)(yaml@2.8.2) + specifier: 6.4.0 + version: 6.4.0(@types/node@22.18.10)(terser@5.31.2)(yaml@2.8.2) webpack: specifier: 5.102.1 version: 5.102.1(@swc/core@1.11.16(@swc/helpers@0.5.12))(esbuild@0.25.10) @@ -4540,8 +4540,8 @@ packages: terser: optional: true - vite@6.3.6: - resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==} + vite@6.4.0: + resolution: {integrity: sha512-oLnWs9Hak/LOlKjeSpOwD6JMks8BeICEdYMJBf6P4Lac/pO9tKiv/XhXnAM7nNfSkZahjlCZu9sS50zL8fSnsw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -9512,7 +9512,7 @@ snapshots: fsevents: 2.3.3 terser: 5.31.2 - vite@6.3.6(@types/node@22.18.10)(terser@5.31.2)(yaml@2.8.2): + vite@6.4.0(@types/node@22.18.10)(terser@5.31.2)(yaml@2.8.2): dependencies: esbuild: 0.25.10 fdir: 6.5.0(picomatch@4.0.3)