diff --git a/packages/void/src/plugin-env-schema.ts b/packages/void/src/plugin-env-schema.ts index 7625d9702..a4ca70308 100644 --- a/packages/void/src/plugin-env-schema.ts +++ b/packages/void/src/plugin-env-schema.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'; import { parseEnv } from 'node:util'; import { join, resolve } from 'pathslash'; -import { loadEnv, resolveEnvPrefix, type Plugin, type ResolvedConfig } from 'vite'; +import { loadEnv, normalizePath, resolveEnvPrefix, type Plugin, type ResolvedConfig } from 'vite'; import { findEnvFile, generateEnvTypes } from './codegen/env-types.ts'; import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { loadUserEnvFile } from './host/load-user-env.ts'; @@ -72,7 +72,7 @@ function stripLocalClientEnv( config: ResolvedConfig, before: ProcessEnvCopies | undefined, ): void { - if (config.envDir === false || realDir(config.envDir) !== realDir(root)) { + if (config.envDir === false || realPath(config.envDir) !== realPath(root)) { return; } const file = join(root, LOCAL_ENV_FILE); @@ -107,14 +107,24 @@ function stripLocalClientEnv( } /** Match Vite's canonical paths, including Windows short names and filesystem casing. */ -function realDir(dir: string): string { +function realPath(path: string): string { try { - return realpathSync.native(dir); + return realpathSync.native(path); } catch { - return resolve(dir); + return resolve(path); } } +/** Module ids that name an env file: its path as found, and as Vite resolves it. */ +function envFileIdsFor(envFile: string | null): Set { + return new Set(envFile ? [normalizePath(envFile), normalizePath(realPath(envFile))] : []); +} + +/** A hook filter pattern that matches exactly `id`. */ +function exactId(id: string): RegExp { + return new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); +} + /** Patterns Vite's `define` typically uses for env-shaped constants. */ const DEFINE_ENV_PATTERN = /^(?:globalThis\.)?(?:process\.env|import\.meta\.env)\.([A-Z_][A-Z0-9_]*)$/; @@ -133,6 +143,22 @@ const DEFINE_ENV_PATTERN = export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = {}): Plugin { const outDir = join(root, '.void'); let envFile: string | null = null; + // `config` finds the file again. The transform filter below keeps the ids found here: a hook + // filter is fixed when the plugin is created. + let envFileIds = envFileIdsFor(findEnvFile(root)); + // Generated server entries import env.ts only to register its schema. Keep that import when + // the app's package.json declares `"sideEffects": false`, which would let the bundler drop it. + // The filter matches the resolved id, which for a symlink is the target's path, whatever its + // file name. + const keepEnvFileImport: Plugin['transform'] = { + filter: { id: [...envFileIds].map(exactId) }, + handler(code, id) { + if (this.environment.config.consumer === 'client' || !envFileIds.has(id)) { + return; + } + return { code, map: null, moduleSideEffects: true }; + }, + }; let command: 'serve' | 'build' = 'serve'; let processEnvCopiesBefore: ProcessEnvCopies | undefined; const appliedClientDefaults = new Map(); @@ -317,6 +343,7 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = throw new Error(reservedEnvKeysMessage('.env', reservedInDotenv)); } envFile = findEnvFile(root); + envFileIds = envFileIdsFor(envFile); if (!envFile) { return; } @@ -392,5 +419,6 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions = await importUserEnv(); } }, + ...(envFileIds.size > 0 && { transform: keepEnvFileImport }), }; } diff --git a/packages/void/test/integration/plugin-env-schema.test.ts b/packages/void/test/integration/plugin-env-schema.test.ts index 08cb08702..b2b448a11 100644 --- a/packages/void/test/integration/plugin-env-schema.test.ts +++ b/packages/void/test/integration/plugin-env-schema.test.ts @@ -1,6 +1,15 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { build, createBuilder, resolveConfig, type InlineConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { envClientFoldPlugin } from '../../src/plugin-env-client-fold.ts'; @@ -558,3 +567,72 @@ describe('envSchemaPlugin reserved __VOID_ names', () => { }, ); }); + +describe('envSchemaPlugin with "sideEffects": false', () => { + const publicEnvUrl = pathToFileURL( + join(import.meta.dirname, '../../src/runtime/env-public.ts'), + ).href; + + /** Build an app that declares no side effects, with an entry that imports env.ts for its side effect. */ + async function buildEntryImportingEnv( + consumer: 'server' | 'client', + options: { symlinkTo?: string } = {}, + ): Promise { + const root = createTempRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', sideEffects: false }), + ); + const schemaFile = join(root, options.symlinkTo ?? 'env.ts'); + writeFileSync( + schemaFile, + `import { defineEnv, number } from ${JSON.stringify(publicEnvUrl)};\n` + + 'export default defineEnv({ SCHEMA_DEFAULT: number().default(48213) });\n', + ); + if (options.symlinkTo) { + symlinkSync(schemaFile, join(root, 'env.ts')); + } + writeFileSync(join(root, 'entry.js'), 'import "./env.ts";\nexport const ready = true;\n'); + const entry = join(root, 'entry.js'); + const output = await build({ + root, + configFile: false, + logLevel: 'silent', + plugins: [envSchemaPlugin(root)], + build: { + ssr: consumer === 'server' ? entry : false, + write: false, + minify: false, + rollupOptions: { + ...(consumer === 'client' && { input: entry }), + external: ['cloudflare:workers'], + }, + }, + }); + return [output] + .flat() + .flatMap((result) => ('output' in result ? result.output : [])) + .map((chunk) => ('code' in chunk ? chunk.code : '')) + .join('\n'); + } + + // Void's server entries import env.ts to register its schema. + it('keeps env.ts in a server build', async () => { + expect(await buildEntryImportingEnv('server')).toContain('48213'); + }); + + // Vite loads a symlinked env.ts under its target's path. Windows needs privileges for a file + // symlink. + it.skipIf(process.platform === 'win32')( + 'keeps a symlinked env.ts whose target has another name', + async () => { + expect(await buildEntryImportingEnv('server', { symlinkTo: 'shared-schema.ts' })).toContain( + '48213', + ); + }, + ); + + it('leaves client builds to the side effects that the app declares', async () => { + expect(await buildEntryImportingEnv('client')).not.toContain('48213'); + }); +});