Skip to content
Merged
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
9 changes: 9 additions & 0 deletions apps/www/content/docs/frameworks/tanstack-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ or `@arkenv/rsbuild-plugin`, depending on your underlying bundler. ArkEnv
inlines public keys (`VITE_` or `PUBLIC_`) into the client bundle, keeps server
secrets inside server functions, and throws if client code reads them.

When the Vite plugin is registered, it discovers and validates `env.ts` during
Vite config resolution. Missing or invalid values abort the dev server or
production build before it is ready, and relevant `.env` or schema changes are
revalidated during HMR. Without the plugin, validation is import-driven and
starts when a module first imports `env.ts`.

This is the existing fail-fast contract, not a new lazy-validation option.
ArkEnv does not add a default-off flag for lazy validation.

For high-level architectural trade-offs, see
[Frameworks](/docs/frameworks).

Expand Down
18 changes: 18 additions & 0 deletions apps/www/content/docs/frameworks/vite.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ description: Learn how to use ArkEnv in a Vite project.
Transform mode inlines public `VITE_` keys into the client bundle and
keeps server secrets out.

## When does validation run?

When `@arkenv/vite-plugin` is registered, it discovers and validates `env.ts`
during Vite config resolution. An invalid or missing value aborts the dev
server or production build before Vite reports that it is ready. Relevant
`.env` and schema changes are validated again during HMR.

Without the plugin, validation is import-driven: `env.ts` runs when an
application module imports it. This distinction applies to both server and
client graphs; the plugin is what lets Vite validate the schema before the
graph is ready and transform client imports safely. See the
[`@arkenv/vite-plugin` reference](/docs/reference/vite-plugin) for the
plugin contract.

This documents the existing fail-fast behavior. ArkEnv does not add a
default-off lazy-validation flag; a separate lazy mode would be an
intentionally scoped feature.

For high-level architectural trade-offs, see
[Frameworks](/docs/frameworks).

Expand Down
12 changes: 12 additions & 0 deletions apps/www/content/docs/reference/vite-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ Call `arkenvPlugin()` with no args, or an options object that includes
transform fields (`schemaPath`, `clientPrefix`, logging). Do not pass a
schema map or compiled `type()` to the plugin.

## Startup validation

When the plugin is registered, it resolves `env.ts` and validates it during
Vite config resolution. Missing or invalid environment variables therefore
abort the dev server or production build before Vite is ready. The plugin
revalidates the schema when a relevant `.env` file or schema module changes
during HMR.

Without the plugin, validation is import-driven and runs when a module imports
`env.ts`. Startup fail-fast is the documented behavior of the existing plugin
contract; there is no default-off lazy-validation flag.

## Next steps

<Cards>
Expand Down
128 changes: 128 additions & 0 deletions packages/vite-plugin/src/env-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,134 @@ describe("transform mode plugin", () => {
}
});

it("fails during config resolution when the environment is invalid", async () => {
const root = mkdtempSync(join(tmpdir(), "arkenv-vite-invalid-startup-"));
temps.push(root);
writeFileSync(
join(root, "env.ts"),
'import arkenv from "@arkenv/core";\n\nexport const env = arkenv({ REQUIRED_TOKEN: "string" });\n',
);
const plugin = arkenvPlugin() as any;
const context = {} as any;

if (plugin.config && typeof plugin.config === "function") {
plugin.config.call(
context,
{ root, envDir: root },
{ mode: "test", command: "serve" },
);
}

await expect(
Promise.resolve().then(() =>
plugin.configResolved?.call(context, {
root,
envDir: root,
envPrefix: "VITE_",
} as any),
),
).rejects.toMatchObject({ name: "ArkEnvError" });
expect(process.env.REQUIRED_TOKEN).toBeUndefined();
});

it("revalidates valid schema and dotenv changes during HMR", async () => {
const root = mkdtempSync(join(tmpdir(), "arkenv-vite-hmr-"));
temps.push(root);
const schemaPath = join(root, "env.ts");
const dotenvPath = join(root, ".env.test");
writeFileSync(
schemaPath,
'import arkenv from "@arkenv/core";\n\nexport const env = arkenv({ VITE_API_URL: "string" });\n',
);
writeFileSync(dotenvPath, "VITE_API_URL=https://example.com\n");
const plugin = arkenvPlugin({ schemaPath }) as any;
const server = {
moduleGraph: {
getModulesByFile: () => new Set([{ id: schemaPath }]),
invalidateModule: () => {},
},
} as any;
const context = {} as any;

plugin.config?.call(
context,
{ root, envDir: root },
{ mode: "test", command: "serve" },
);
await plugin.configResolved?.call(context, {
root,
envDir: root,
envPrefix: "VITE_",
} as any);

const schemaUpdate = plugin.handleHotUpdate?.call(context, {
file: schemaPath,
server,
} as any);
expect(schemaUpdate).toHaveLength(1);
writeFileSync(dotenvPath, "VITE_API_URL=https://updated.example.com\n");
const dotenvUpdate = plugin.handleHotUpdate?.call(context, {
file: dotenvPath,
server,
} as any);
expect(dotenvUpdate).toHaveLength(1);
Comment thread
yamcodes marked this conversation as resolved.

const clientModule = await plugin.transform?.call(
{
environment: {
name: "client",
config: { consumer: "client" },
},
},
"export const env = {}",
schemaPath,
);
expect(clientModule?.code).toContain(
'"VITE_API_URL": "https://updated.example.com"',
);
});

it("propagates invalid dotenv values during HMR", async () => {
const root = mkdtempSync(join(tmpdir(), "arkenv-vite-invalid-hmr-"));
temps.push(root);
const schemaPath = join(root, "env.ts");
const dotenvPath = join(root, ".env.test");
writeFileSync(
schemaPath,
'import arkenv from "@arkenv/core";\n\nexport const env = arkenv({ VITE_PORT: "number" });\n',
);
writeFileSync(dotenvPath, "VITE_PORT=8080\n");
const plugin = arkenvPlugin({ schemaPath }) as any;
const server = {
moduleGraph: {
getModulesByFile: () => new Set(),
invalidateModule: () => {},
},
} as any;
const context = {} as any;

plugin.config?.call(
context,
{ root, envDir: root },
{ mode: "test", command: "serve" },
);
await plugin.configResolved?.call(context, {
root,
envDir: root,
envPrefix: "VITE_",
} as any);
writeFileSync(dotenvPath, "VITE_PORT=not-a-number\n");

await expect(
Promise.resolve().then(() =>
plugin.handleHotUpdate?.call(context, {
file: dotenvPath,
server,
} as any),
),
).rejects.toMatchObject({ name: "ArkEnvError" });
});

it("passes through the env module unchanged in the SSR graph", async () => {
const fixtureDir = join(__dirname, "__fixtures__", "transform-env");
const plugin = arkenvPlugin({ schemaPath: join(fixtureDir, "env.ts") });
Expand Down
Loading