From 2d432af79bcb3af7b0596b97f4d5a7ea77f3ddcd Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Sat, 7 Nov 2026 21:28:08 +0900 Subject: [PATCH] fix(env): keep env.ts in Worker builds of apps without side effects When an app's package.json declares "sideEffects": false, a production build dropped env.ts from the Worker. The generated entry imports env.ts only for its side effect, registering the schema, so the bundler removed that import: env read raw values, and boot validation passed anything. envSchemaPlugin now marks env.ts as having side effects in server builds. Rolldown honors the plugin's value over the package's sideEffects field. The transform filter matches the env file's path as found and as Vite resolves it, so a symlinked env.ts is covered whatever its target is named. Client builds are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/plugin-env-schema.ts | 38 +++++++-- .../integration/plugin-env-schema.test.ts | 80 ++++++++++++++++++- 2 files changed, 112 insertions(+), 6 deletions(-) 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'); + }); +});