Created
October 13, 2025 19:55
-
-
Save neolectron/334f513ac6d936288f8379cc39959a2d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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