vite-envs
Version:
Env var in Vite at container startup
821 lines (682 loc) • 32.7 kB
text/typescript
import {
join as pathJoin,
sep as pathSep,
posix as posixPath,
resolve as pathResolve,
basename as pathBasename,
relative as pathRelative,
normalize as pathNormalize
} from "path";
import type { Plugin, ResolvedConfig } from "vite";
import { assert } from "tsafe/assert";
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import * as fs from "fs";
import { viteEnvsMetaFileBasename, updateTypingScriptEnvName } from "./constants";
import { getScriptThatDefinesTheGlobal } from "./getScriptThatDefinesTheGlobal";
import { injectInHeadBeforeFirstScriptTag } from "./injectInHeadBeforeFirstScriptTag";
import { renderHtmlAsEjs } from "./renderHtmlAsEjs";
import { substituteHtmPlaceholders } from "./substituteHtmPlaceholders";
import type { ViteEnvsMeta } from "./ViteEnvsMeta";
import { transformCodebase } from "./tools/transformCodebase";
import { exclude } from "tsafe/exclude";
import { getAbsoluteAndInOsFormatPath } from "./tools/getAbsoluteAndInOsFormatPath";
import MagicString from "magic-string";
import { createSwEnvJsFile } from "./createSwEnvJsFile";
import { parseDotEnv, parseEnvValue } from "./parseDotEnv";
export function viteEnvs(params?: {
computedEnv?:
| Record<string, unknown>
| ((params: {
resolvedConfig: ResolvedConfig;
declaredEnv: Record<string, string>;
dotEnvLocal: Record<string, string>;
}) => Promise<Record<string, unknown>> | Record<string, unknown>);
/** Default: .env */
declarationFile?: string;
/**
* Default: false
* Enabling this option requires to have Node available in the container.
* See documentation for more information.
*/
indexAsEjs?: boolean;
/**
* Default: ({ appRootDirPath }) => pathJoin(appRootDirPath, "src", "vite-env.d.ts")
*/
ambientModuleDeclarationFilePath?: string | ((params: { appRootDirPath: string }) => string);
/**
* Default: "__VITE_ENVS"
* Provide this value if you want to change name of the global var (in MFE context for example).
* Should not be a key of window or self object, best practice is using "__" as prefix.
*/
nameOfTheGlobal?: string;
}) {
const {
computedEnv: computedEnv_params,
declarationFile = ".env",
indexAsEjs = false,
ambientModuleDeclarationFilePath: ambientModuleDeclarationFilePath_params,
nameOfTheGlobal = "__VITE_ENVS"
} = params ?? {};
const getAmbientModuleDeclarationFilePath: (params: { appRootDirPath: string }) => string = (() => {
if (ambientModuleDeclarationFilePath_params === undefined) {
return ({ appRootDirPath }) => pathJoin(appRootDirPath, "src", "vite-env.d.ts");
}
if (typeof ambientModuleDeclarationFilePath_params === "string") {
return () => ambientModuleDeclarationFilePath_params;
}
return ambientModuleDeclarationFilePath_params;
})();
const getComputedEnv =
typeof computedEnv_params === "function" ? computedEnv_params : () => computedEnv_params ?? {};
let resultOfConfigResolved:
| {
appRootDirPath: string;
baseBuildTimeEnv: Record<string, unknown>;
declaredEnv: Record<string, string>;
computedEnv: Record<string, unknown>;
dotEnv: Record<string, string>;
dotEnvLocal: Record<string, string>;
shouldGenerateSourcemap: boolean;
buildInfos:
| {
distDirPath: string;
assetsUrlPath: string;
}
| undefined;
}
| undefined = undefined;
let htmlPre: string | undefined = undefined;
const tmpRefString = "VITE_ENVS_TMP_REF_XS3SLW";
const getMergedEnv = () => {
assert(resultOfConfigResolved !== undefined);
const { baseBuildTimeEnv, declaredEnv, dotEnv, dotEnvLocal, computedEnv, appRootDirPath } =
resultOfConfigResolved;
const mergedEnv = {
...Object.fromEntries(
Object.entries({
...baseBuildTimeEnv,
...computedEnv
}).map(([key, value]) => [key, key in declaredEnv ? `${value}` : value])
),
...Object.fromEntries(
Object.entries(declaredEnv).filter(
([key, value]) => !(key in computedEnv && value === "")
)
),
...(getAbsoluteAndInOsFormatPath({
"cwd": appRootDirPath,
"pathIsh": declarationFile
}) ===
getAbsoluteAndInOsFormatPath({
"cwd": appRootDirPath,
"pathIsh": ".env"
})
? undefined
: dotEnv),
...dotEnvLocal,
...Object.fromEntries(
Object.entries(process.env)
.map(([key, value]) => (value === undefined ? undefined : ([key, value] as const)))
.filter(exclude(undefined))
.filter(([key]) => key in declaredEnv)
.map(([key, value]) => [key, parseEnvValue(value)])
)
};
return { mergedEnv };
};
const plugin = {
"name": "vite-envs",
"configResolved": async resolvedConfig => {
const appRootDirPath = resolvedConfig.root;
const baseBuildTimeEnv: Record<string, unknown> = resolvedConfig.env;
const declaredEnv = (() => {
const declarationEnvFilePath = getAbsoluteAndInOsFormatPath({
"cwd": appRootDirPath,
"pathIsh": declarationFile
});
if (!fs.existsSync(declarationEnvFilePath)) {
throw new Error(
`There is no ${pathRelative(appRootDirPath, declarationEnvFilePath)}`
);
}
const parsed = parseDotEnv({
"path": declarationEnvFilePath
});
return parsed;
})();
const [dotEnv, dotEnvLocal] = [".env", ".env.local"].map(fileBasename => {
const filePath = pathJoin(appRootDirPath, fileBasename);
if (!fs.existsSync(filePath)) {
return {};
}
const parsed = parseDotEnv({
"path": filePath
});
return Object.fromEntries(Object.entries(parsed).filter(([key]) => key in declaredEnv));
});
const computedEnv = await getComputedEnv({ resolvedConfig, declaredEnv, dotEnvLocal });
resultOfConfigResolved = {
appRootDirPath,
baseBuildTimeEnv,
declaredEnv,
computedEnv,
dotEnv,
dotEnvLocal,
"shouldGenerateSourcemap": resolvedConfig.build.sourcemap !== false,
"buildInfos": undefined
};
{
const viteDirPath = (function callee(depth: number): string {
const cwd = pathResolve(pathJoin(...[appRootDirPath, ...Array(depth).fill("..")]));
const viteDirPath = pathJoin(cwd, "node_modules", "vite");
if (!fs.existsSync(pathJoin(viteDirPath, "package.json"))) {
if (cwd === pathSep) {
throw new Error("Could not find vite directory");
}
return callee(depth + 1);
}
return viteDirPath;
})(0);
transformCodebase({
"srcDirPath": viteDirPath,
"destDirPath": getThisCodebaseRootDirPath(),
"transformSourceCode": ({ sourceCode, fileRelativePath }) => {
if (fileRelativePath === "client.d.ts") {
let modifiedSourceCodeStr = sourceCode.toString("utf8");
modifiedSourceCodeStr = modifiedSourceCodeStr.replace(
/^\s *\/\/\/\s*<reference\s+types=["']\.\/types\/importMeta.d.ts["']\s*\/>/,
""
);
return { "modifiedSourceCode": Buffer.from(modifiedSourceCodeStr, "utf8") };
}
if (
fileRelativePath.startsWith("types") &&
pathBasename(fileRelativePath) !== "importMeta.d.ts"
) {
return { "modifiedSourceCode": sourceCode };
}
return undefined;
}
});
{
const ambientModuleDeclarationFilePath = getAmbientModuleDeclarationFilePath({
appRootDirPath
});
let dTsFileContent = !fs.existsSync(ambientModuleDeclarationFilePath)
? `/// <reference types="vite/client" />\n`
: fs.readFileSync(ambientModuleDeclarationFilePath).toString("utf8");
dTsFileContent = dTsFileContent.replace(
/^\s*\/\/\/\s*<reference\s+types=["']vite\/client["']\s*\/>\s*\r?\n?/,
'/// <reference types="vite-envs/client" />\n'
);
const userDefinedStartPlaceholder = "// @user-defined-start";
const userDefinedEndPlaceholder = "// @user-defined-end";
const userDefinedSection = (() => {
const defaultUserDefinedSection = [
"",
" /*",
" * You can use this section to explicitly extend the type definition of `import.meta.env`",
" * This is useful if you're using Vite plugins that define specific `import.meta.env` properties.",
" * If you're not using such plugins, this section should remain as is.",
" */",
" SSR: boolean;",
" "
].join("\n");
const userDefinedStartIndex = dTsFileContent.indexOf(
userDefinedStartPlaceholder
);
if (userDefinedStartIndex === -1) {
return defaultUserDefinedSection;
}
const userDefinedEndIndex = dTsFileContent.indexOf(userDefinedEndPlaceholder);
if (userDefinedEndIndex === -1) {
return defaultUserDefinedSection;
}
const userDefinedSection = dTsFileContent.slice(
userDefinedStartIndex + userDefinedStartPlaceholder.length,
userDefinedEndIndex
);
return userDefinedSection;
})();
{
const generatedContent = [
`type ImportMetaEnv = {`,
" // Auto-generated by `npx vite-envs update-types` and hot-reloaded by the `vite-env` plugin",
" // You probably want to add `/src/vite-env.d.ts` to your .prettierignore",
...Object.entries({
...baseBuildTimeEnv,
...computedEnv,
...declaredEnv
}).map(([key, value]) => {
const valueType = (() => {
if (typeof value === "string") {
return "string";
}
if (typeof value === "boolean") {
return "boolean";
}
if (typeof value === "number") {
return "number";
}
if (Array.isArray(value)) {
return "any[]";
}
return "any";
})();
return ` ${key}: ${valueType}`;
}),
` ${userDefinedStartPlaceholder}${userDefinedSection}${userDefinedEndPlaceholder}`,
`}`
].join("\n");
const dTsFileContent_before = dTsFileContent;
const placeholder = "xKdSwPrRw3zAaxBbcPtMmQqLlNnJjKkHhGgFfEeDdCcBbAaDxx77";
dTsFileContent = dTsFileContent.replace(
/(?:type|interface)\s+ImportMetaEnv\s*=?\s*{[^}]*};?/g,
placeholder
);
if (dTsFileContent === dTsFileContent_before) {
dTsFileContent += `\n\n${generatedContent}\n\n`;
} else {
dTsFileContent = dTsFileContent.replace(placeholder, generatedContent);
}
}
{
const generatedContent = [
`interface ImportMeta {`,
" // Auto-generated by `npx vite-envs update-types`",
``,
` url: string`,
``,
` readonly hot?: import('vite-envs/types/hot').ViteHotContext`,
``,
` readonly env: ImportMetaEnv`,
``,
` glob: import('vite-envs/types/importGlob').ImportGlobFunction`,
`}`
].join("\n");
const dTsFileContent_before = dTsFileContent;
const placeholder = "ssDdCcBbAaDxx7xKdSwPrRw3zAaxBbcFfEeDdCcBbAaDxx77";
dTsFileContent = dTsFileContent.replace(
/interface\s+ImportMeta\s*{[^}]*};?/g,
placeholder
);
if (dTsFileContent === dTsFileContent_before) {
dTsFileContent += `\n\n${generatedContent}\n\n`;
} else {
dTsFileContent = dTsFileContent.replace(placeholder, generatedContent);
}
}
fs.writeFileSync(
ambientModuleDeclarationFilePath,
Buffer.from(dTsFileContent, "utf8")
);
}
if (updateTypingScriptEnvName in process.env) {
process.exit(0);
}
}
define_build_infos: {
if (resolvedConfig.command !== "build") {
break define_build_infos;
}
resultOfConfigResolved.buildInfos = {
"distDirPath": pathJoin(appRootDirPath, resolvedConfig.build.outDir),
"assetsUrlPath": posixPath.join(
resolvedConfig.env.BASE_URL,
resolvedConfig.build.assetsDir
)
};
}
},
"transform": (code, id) => {
// Skip special files.
if (id.startsWith("\x00")) {
return null;
}
assert(resultOfConfigResolved !== undefined);
const {
appRootDirPath,
baseBuildTimeEnv,
computedEnv,
declaredEnv,
shouldGenerateSourcemap
} = resultOfConfigResolved;
const filePath = pathNormalize(id);
{
const isWithinSourceDirectory = filePath.startsWith(
pathNormalize(pathJoin(appRootDirPath, "src") + pathSep)
);
if (!isWithinSourceDirectory) {
return;
}
}
{
const isJavascriptFile = filePath.endsWith(".js") || filePath.endsWith(".jsx");
const isTypeScriptFile = filePath.endsWith(".ts") || filePath.endsWith(".tsx");
const isVue = filePath.endsWith(".vue");
if (!isTypeScriptFile && !isJavascriptFile && !isVue) {
return;
}
}
if (!code.includes("import.meta.env")) {
return;
}
const transformedCode = new MagicString(code);
transformedCode.replace(
/import\.meta\.env(?:\.([A-Za-z0-9$_]+)|\["([^"]+)"\]|(.?))/g,
(match, p1, p2, p3) => {
const out = (() => {
const globalRef = `window.${nameOfTheGlobal}`;
if (p3 !== undefined) {
return `${globalRef}${p3}`;
}
const varName = p1 || p2;
assert(typeof varName === "string");
const isUnknownVar =
!(varName in declaredEnv) &&
!(varName in baseBuildTimeEnv) &&
!(varName in computedEnv);
if (isUnknownVar) {
// NOTE: We don't modify the code if the variable is unknown.
return match;
}
return `${globalRef}${p1 !== undefined ? `.${p1}` : `["${p2}"]`}`;
})();
return out;
}
);
if (!transformedCode.hasChanged()) {
return;
}
if (!shouldGenerateSourcemap) {
return transformedCode.toString();
}
const map = transformedCode.generateMap({
"source": filePath,
"includeContent": true,
"hires": true
});
return {
"code": transformedCode.toString(),
"map": map.toString()
};
},
"transformIndexHtml": {
"order": "pre",
"handler": (() => {
const handler_ejs = async (html: string) => {
assert(resultOfConfigResolved !== undefined);
const { buildInfos } = resultOfConfigResolved;
if (buildInfos !== undefined) {
htmlPre = html;
}
const { mergedEnv } = getMergedEnv();
let processedHtml = await renderHtmlAsEjs({
html,
"env": mergedEnv
});
processedHtml = substituteHtmPlaceholders({
"html": processedHtml,
"env": mergedEnv
});
processedHtml = injectInHeadBeforeFirstScriptTag({
"html": processedHtml,
"htmlToInject": getScriptThatDefinesTheGlobal({
"env": mergedEnv,
nameOfTheGlobal
})
});
return processedHtml;
};
const handler_noEjs = (html: string) => {
assert(resultOfConfigResolved !== undefined);
const { buildInfos } = resultOfConfigResolved;
const { mergedEnv } = getMergedEnv();
const action_buildMode = () => {
const htmlPostSubstitution = substituteHtmPlaceholders({
html,
"env": Object.fromEntries(
Object.keys(mergedEnv).map(key => [key, `${tmpRefString}(${key})`])
)
});
return htmlPostSubstitution;
};
const action_devMode = () => {
let processedHtml = substituteHtmPlaceholders({
html,
"env": mergedEnv
});
processedHtml = injectInHeadBeforeFirstScriptTag({
"html": processedHtml,
"htmlToInject": getScriptThatDefinesTheGlobal({
"env": mergedEnv,
nameOfTheGlobal
})
});
return processedHtml;
};
return buildInfos === undefined ? action_devMode() : action_buildMode();
};
return indexAsEjs ? handler_ejs : handler_noEjs;
})()
},
"closeBundle": (() => {
const closeBundle_ejs = () => {
assert(resultOfConfigResolved !== undefined);
const { baseBuildTimeEnv, declaredEnv, computedEnv, buildInfos } =
resultOfConfigResolved;
if (buildInfos === undefined) {
return;
}
assert(htmlPre !== undefined);
const { assetsUrlPath, distDirPath } = buildInfos;
if (!fs.existsSync(distDirPath)) {
// There was an error in the build
return;
}
const viteEnvsMeta: ViteEnvsMeta = {
"version": JSON.parse(
fs
.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json"))
.toString("utf8")
).version,
assetsUrlPath,
declaredEnv,
computedEnv,
baseBuildTimeEnv,
htmlPre,
nameOfTheGlobal
};
fs.writeFileSync(
pathJoin(distDirPath, viteEnvsMetaFileBasename),
Buffer.from(JSON.stringify(viteEnvsMeta, null, 4), "utf8")
);
const { mergedEnv } = getMergedEnv();
createSwEnvJsFile({ distDirPath, mergedEnv, nameOfTheGlobal });
};
const closeBundle_noEjs = () => {
assert(resultOfConfigResolved !== undefined);
const { buildInfos, baseBuildTimeEnv, computedEnv, declaredEnv } =
resultOfConfigResolved;
if (buildInfos === undefined) {
return;
}
const { distDirPath } = buildInfos;
if (!fs.existsSync(distDirPath)) {
// There was an error in the build
return;
}
const indexHtmlFilePath = pathJoin(distDirPath, "index.html");
let processedHtml = fs.readFileSync(indexHtmlFilePath).toString("utf8");
processedHtml = processedHtml.replace(
new RegExp(`${tmpRefString}\\(([^)]+)\\)`, "g"),
(_, key) => `%${key}%`
);
// NOTE: We can't use cheerio because not a valid HTML document.
const injectInHeadBeforeFirstScriptTag = (params: {
html: string;
htmlToInject: string;
}) => {
const { html, htmlToInject } = params;
const sep = "<script ";
const [p1, ...rest] = html.split(sep);
return [p1 + htmlToInject, ...rest].join(sep);
};
// NOTE: Make is so running the ./vite-envs.sh script is optional.
((processedHtml: string) => {
const { mergedEnv } = getMergedEnv();
processedHtml = substituteHtmPlaceholders({
"html": processedHtml,
"env": mergedEnv
});
processedHtml = injectInHeadBeforeFirstScriptTag({
"html": processedHtml,
"htmlToInject": getScriptThatDefinesTheGlobal({
"env": mergedEnv,
nameOfTheGlobal
})
});
fs.writeFileSync(indexHtmlFilePath, Buffer.from(processedHtml, "utf8"));
createSwEnvJsFile({ distDirPath, mergedEnv, nameOfTheGlobal });
})(processedHtml);
const placeholderForViteEnvsScript = `<!-- vite-envs script placeholder xKsPmLs30swKsdIsVx -->`;
processedHtml = injectInHeadBeforeFirstScriptTag({
"html": processedHtml,
"htmlToInject": placeholderForViteEnvsScript
});
const scriptPath = pathJoin(distDirPath, "vite-envs.sh");
const singularString = "xPsZs9swrPvxYpC";
const singularString2 = "xApWdRrX99kPrVggE";
const buildTimeMergedEnv = {
...Object.fromEntries(
Object.entries({
...baseBuildTimeEnv,
...computedEnv
}).map(([key, value]) => [key, key in declaredEnv ? `${value}` : value])
),
...Object.fromEntries(
Object.entries(declaredEnv).filter(
([key, value]) => !(key in computedEnv && value === "")
)
)
};
const scriptContent = [
`#!/bin/sh`,
``,
`replaceAll() {`,
` export inputString="$1"`,
` export pattern="$2"`,
` export replacement="$3"`,
``,
` echo "$inputString" | awk '{`,
` gsub(ENVIRON["pattern"], ENVIRON["replacement"])`,
` print`,
` }'`,
`}`,
``,
`html=$(echo "${Buffer.from(processedHtml, "utf8").toString(
"base64"
)}" | base64 -d)`,
``,
...Object.entries(buildTimeMergedEnv)
.map(([name, value]) => {
const [valueB64, valueB64_prefixed] = ([false, true] as const).map(
doUsePrefix =>
Buffer.from(
`${
doUsePrefix
? `${singularString2}${JSON.stringify(value)}`
: `${value}`
}\n`,
"utf8"
).toString("base64")
);
if (!(name in declaredEnv)) {
return [
`${name}_base64="${valueB64_prefixed}"`,
`${name}=$(echo "${valueB64}" | base64 -d)`
];
}
return [
`if printenv ${name} &> /dev/null; then`,
` ${name}_base64=$(printenv ${name} | base64)`,
`else`,
` ${name}_base64="${valueB64_prefixed}"`,
`fi`,
`${name}=\${${name}:-$(echo "${valueB64}" | base64 -d)}`
];
})
.flat(),
``,
`processedHtml="$html"`,
``,
...Object.keys(buildTimeMergedEnv).map(
name =>
`processedHtml=$(replaceAll "$processedHtml" "%${name}%" "${name}${singularString}")`
),
``,
...Object.keys(buildTimeMergedEnv).map(
name =>
`processedHtml=$(replaceAll "$processedHtml" "${name}${singularString}" "\$${name}")`
),
``,
`json=""`,
`json="$json{"`,
...Object.keys(buildTimeMergedEnv).map(
(name, i, names) =>
`json="$json\\"${name}\\":\\\`\$${name}_base64\\\`${
i === names.length - 1 ? "" : ","
}"`
),
`json="$json}"`,
``,
`script="`,
` <script data-script-description=\\"Environment variables injected by vite-envs\\">`,
` (function (){`,
` var envWithValuesInBase64 = $json;`,
` var env = {};`,
` Object.keys(envWithValuesInBase64).forEach(function (name) {`,
` const value = new TextDecoder().decode(`,
` Uint8Array.from(`,
` atob(envWithValuesInBase64[name]),`,
` c => c.charCodeAt(0))`,
` ).slice(0,-1);`,
` env[name] = value.startsWith(\\"${singularString2}\\") ? JSON.parse(value.slice(\\"${singularString2}\\".length)) : value;`,
` });`,
` window.${nameOfTheGlobal} = env;`,
` })();`,
` </script>"`,
``,
`scriptPlaceholder="${placeholderForViteEnvsScript}"`,
``,
`processedHtml=$(replaceAll "$processedHtml" "$scriptPlaceholder" "$script")`,
``,
`DIR=$(cd "$(dirname "$0")" && pwd)`,
``,
`echo "$processedHtml" > "$DIR/index.html"`,
``,
`swEnv_script="`,
`const envWithValuesInBase64 = $json;`,
`const env = {};`,
`Object.keys(envWithValuesInBase64).forEach(function (name) {`,
` const value = new TextDecoder().decode(`,
` Uint8Array.from(`,
` atob(envWithValuesInBase64[name]),`,
` c => c.charCodeAt(0))`,
` ).slice(0,-1);`,
` env[name] = value.startsWith(\\"${singularString2}\\") ? JSON.parse(value.slice(\\"${singularString2}\\".length)) : value;`,
`});`,
`self.${nameOfTheGlobal} = env;`,
`"`,
``,
`echo "$swEnv_script" > "$DIR/swEnv.js" || echo "Not enough permissions to write to $DIR/swEnv.js"`,
``
].join("\n");
fs.writeFileSync(scriptPath, Buffer.from(scriptContent, "utf8"));
fs.chmodSync(scriptPath, "755");
};
return indexAsEjs ? closeBundle_ejs : closeBundle_noEjs;
})()
} satisfies Plugin;
return plugin as any;
}