Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions packages/void/src/plugin-env-schema.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string> {
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_]*)$/;
Expand All @@ -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<string, string>();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -392,5 +419,6 @@ export function envSchemaPlugin(root: string, options: EnvSchemaPluginOptions =
await importUserEnv();
}
},
...(envFileIds.size > 0 && { transform: keepEnvFileImport }),
};
}
80 changes: 79 additions & 1 deletion packages/void/test/integration/plugin-env-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string> {
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');
});
});