Skip to content

Instantly share code, notes, and snippets.

@neolectron
Created October 13, 2025 19:55
Show Gist options
  • Select an option

  • Save neolectron/334f513ac6d936288f8379cc39959a2d to your computer and use it in GitHub Desktop.

Select an option

Save neolectron/334f513ac6d936288f8379cc39959a2d to your computer and use it in GitHub Desktop.
import type { Plugin } from "vite";
import { relative } from "node:path";
type MinimalPluginContext = {
resolve(
this: MinimalPluginContext,
source: string,
importer?: string,
options?: { skipSelf?: boolean },
): Promise<{ id: string } | null>;
warn(message: string): void;
};
const cwd = process.cwd();
const cleanVirtualPrefix = (id: string) => {
let clean = id;
while (clean.length > 0 && clean.charCodeAt(0) === 0) {
clean = clean.slice(1);
}
return clean;
};
const stripQuery = (id: string) => id.split("?")[0];
const normalizeId = (id: string | undefined) => {
if (!id) return undefined;
return cleanVirtualPrefix(stripQuery(id));
};
const formatModuleId = (id: string | undefined) => {
if (!id) return "<unknown>";
const cleaned = cleanVirtualPrefix(stripQuery(id));
const rel = relative(cwd, cleaned);
if (!rel || rel.startsWith("..")) return cleaned;
return rel;
};
const collectImportChains = (
startId: string,
getParents: (id: string) => readonly string[],
maxChains = 10,
) => {
const chains: string[][] = [];
const stack: { id: string; path: string[] }[] = [{ id: startId, path: [startId] }];
while (stack.length && chains.length < maxChains) {
const next = stack.pop();
if (!next) break;
const { id, path } = next;
if (path.length > 16) {
chains.push([...path, "(truncated)"]);
continue;
}
const parents = getParents(id);
if (!parents.length) {
chains.push(path);
continue;
}
for (const importerId of parents) {
if (chains.length >= maxChains) break;
if (path.includes(importerId)) {
chains.push([...path, importerId, "(cycle)"]);
continue;
}
stack.push({ id: importerId, path: [...path, importerId] });
}
}
return chains;
};
export const serverOnlyEnvConfigPlugin = ({ pattern }: { pattern: string }): Plugin => {
const stubId = "\u0000server-only-env-config";
const flaggedImporters = new Set<string>();
const childToParents = new Map<string, Set<string>>();
const trackEdge = (child: string, parent: string) => {
let parents = childToParents.get(child);
if (!parents) {
parents = new Set();
childToParents.set(child, parents);
}
parents.add(parent);
};
const getParents = (id: string) => {
const parents = childToParents.get(id);
return parents ? [...parents] : [];
};
return {
name: "server-only-env-config",
enforce: "pre",
async resolveId(
this: MinimalPluginContext,
source: string,
importer: string | undefined,
options,
) {
const isBrowserBuild = !(options?.ssr ?? false);
if (importer) {
const parentId = normalizeId(importer);
const resolved = await this.resolve(source, importer, {
skipSelf: true,
});
const candidateId = normalizeId(resolved?.id ?? source);
if (parentId && candidateId) {
const childId = candidateId;
trackEdge(childId, parentId);
}
}
if (isBrowserBuild && new RegExp(pattern).test(source)) {
const importerPath = importer ?? "<entry>";
const warningKey = normalizeId(importerPath) ?? importerPath;
if (!flaggedImporters.has(warningKey)) {
flaggedImporters.add(warningKey);
const chains = importer ? collectImportChains(warningKey, getParents) : [];
const formattedChains = chains.map((chain) =>
chain.map((step) => (step.startsWith("(") ? step : formatModuleId(step))).join(" -> "),
);
const chainSuffix = formattedChains.length
? `\n import chain(s):\n ${formattedChains.join("\n ")}`
: "";
this.warn(
`server-only-env-config: ${formatModuleId(warningKey)} imported ${source} in a client bundle.${chainSuffix}`,
);
}
return stubId;
}
return null;
},
load(id) {
if (id === stubId) {
const lines = [
'const message = "env.config.ts is server-only";',
"const env = new Proxy({}, {",
" get: (_target, prop) => {",
' throw new Error(`${message}: attempted to read "${String(prop)}" in a browser bundle.`);',
" },",
"});",
"export { env };",
];
return lines.join("\n");
}
return null;
},
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment