Build and Runtime Security
Physically remove server fragments from client bundles without interpreting application code.
Envil protects the server/client boundary at three levels:
appEnv.clientandappEnv.serverhave different output and requirement types.- Runtime evaluation selects only definitions available to the current target.
- Build plugins remove complete
server(...)expressions from client module graphs before bundling.
The returned environment is also protected by an immutable Proxy. Reading a
value that was not materialized produces an EnvironmentAccessError; executing
appEnv.server in a proven client build produces a
ServerEnvironmentAccessError.
Core behavior without a plugin
Without a build plugin, Envil detects ordinary browser, Node, and Bun runtimes:
- Browsers are client runtimes.
- Node and Bun are server runtimes.
- Ambiguous runtimes fail closed when
appEnv.serverexecutes.
The check is lazy. If it fails, Envil does not read the server runtime source or execute its resolvers.
Core runtime checks prevent server values from being materialized in the browser, but cannot control which source modules a bundler includes. Use a build plugin when server definitions and their dependency graph must be physically absent.
Compiler boundary
The compiler treats server(...) as a target intrinsic. In a client transform,
it replaces the complete call expression with undefined before the bundler
follows the resulting module graph. The active bundler resolves module paths;
Envil follows their static imports and re-exports, including renamed local and
package barrels:
const appEnv = createEnv(
shared({ APP_NAME: "My App" }),
server(makeServerDefinitions()),
client(
{
...publicSchemas,
APP_URL: makePublicUrlSchema(),
},
{ runtimeEnv: import.meta.env },
),
);
The client transform removes server(makeServerDefinitions()) as one unit. It
does not execute or statically interpret makeServerDefinitions, Effect
Schemas, spreads, resolver adapters, or imported helpers. A top-level
configureResolver(...) binding is also removed when every reference to it is
inside a pruned server fragment. The server transform keeps every target because
appEnv.server exposes the complete environment.
This boundary is what makes arbitrary Effect Schema and custom resolver code compatible with compilation: surviving code remains ordinary runtime code with its original references and requirements.
Once a server fragment expression is removed, the bundler decides which now unreachable imports and modules can be discarded. Envil marks its package as side-effect free, but cannot promise removal of unrelated top-level side effects inside an application or dependency module.
Vite
import { defineConfig } from "vite";
import envil from "@ayronforge/envil/plugins/vite";
export default defineConfig({
plugins: [envil()],
});
Vite identifies client and SSR graphs for each transform. The plugin injects the exact runtime target, prunes server fragments only from client graphs, and excludes Envil from dependency pre-bundling so the transform is not skipped.
Rollup and Rolldown
import envil from "@ayronforge/envil/plugins/rollup";
export default {
plugins: [envil({ target: "client" })],
};
Use @ayronforge/envil/plugins/rolldown with the same options for Rolldown.
webpack and Rspack
import EnvilPlugin from "@ayronforge/envil/plugins/webpack";
export default {
plugins: [EnvilPlugin({ target: "client" })],
};
Use @ayronforge/envil/plugins/rspack for Rspack.
esbuild
import envil from "@ayronforge/envil/plugins/esbuild";
await build({
plugins: [envil({ target: "client" })],
});
Generic bundler adapters default to client so an omitted target fails closed.
Pass target: "server" explicitly for a server build.
Expo
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["@ayronforge/envil/plugins/expo"],
};
};
The Expo plugin runs the Envil compiler before Expo parses and inlines public
environment references. Alongside normal server-fragment pruning, it expands
the expo preset into one static process.env.EXPO_PUBLIC_* property access
for every inline client schema key. Import compiler functions and the preset
directly from @ayronforge/envil and @ayronforge/envil/presets; Babel does
not expose Metro’s module resolver, so this plugin does not infer their identity
through barrels.
What the Proxy guarantees
const clientEnvironment = await Effect.runPromise(appEnv.client);
clientEnvironment.PUBLIC_URL; // available
clientEnvironment.DATABASE_URL; // TypeScript error; runtime error if bypassed
Enumeration, object spread, and JSON serialization expose only materialized values. Mutation, deletion, and property redefinition are rejected.
Security boundary
Envil prevents accidental cross-target access and composition. It cannot make a hard-coded literal secret safe. Production secrets should come from a server runtime source or resolver. Resolver values are authoritative, automatically redacted, and never executed by the client Effect.