Skip to content

Commit af94f54

Browse files
authored
Merge pull request #1840 from yamcodes/yamcodes-formalize-vite-startup-validation
fix: Formalize Vite startup validation guarantee and document the plugin contract
1 parent 98707c2 commit af94f54

4 files changed

Lines changed: 167 additions & 0 deletions

File tree

apps/www/content/docs/frameworks/tanstack-start.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ or `@arkenv/rsbuild-plugin`, depending on your underlying bundler. ArkEnv
88
inlines public keys (`VITE_` or `PUBLIC_`) into the client bundle, keeps server
99
secrets inside server functions, and throws if client code reads them.
1010

11+
When the Vite plugin is registered, it discovers and validates `env.ts` during
12+
Vite config resolution. Missing or invalid values abort the dev server or
13+
production build before it is ready, and relevant `.env` or schema changes are
14+
revalidated during HMR. Without the plugin, validation is import-driven and
15+
starts when a module first imports `env.ts`.
16+
17+
This is the existing fail-fast contract, not a new lazy-validation option.
18+
ArkEnv does not add a default-off flag for lazy validation.
19+
1120
For high-level architectural trade-offs, see
1221
[Frameworks](/docs/frameworks).
1322

apps/www/content/docs/frameworks/vite.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ description: Learn how to use ArkEnv in a Vite project.
77
Transform mode inlines public `VITE_` keys into the client bundle and
88
keeps server secrets out.
99

10+
## When does validation run?
11+
12+
When `@arkenv/vite-plugin` is registered, it discovers and validates `env.ts`
13+
during Vite config resolution. An invalid or missing value aborts the dev
14+
server or production build before Vite reports that it is ready. Relevant
15+
`.env` and schema changes are validated again during HMR.
16+
17+
Without the plugin, validation is import-driven: `env.ts` runs when an
18+
application module imports it. This distinction applies to both server and
19+
client graphs; the plugin is what lets Vite validate the schema before the
20+
graph is ready and transform client imports safely. See the
21+
[`@arkenv/vite-plugin` reference](/docs/reference/vite-plugin) for the
22+
plugin contract.
23+
24+
This documents the existing fail-fast behavior. ArkEnv does not add a
25+
default-off lazy-validation flag; a separate lazy mode would be an
26+
intentionally scoped feature.
27+
1028
For high-level architectural trade-offs, see
1129
[Frameworks](/docs/frameworks).
1230

apps/www/content/docs/reference/vite-plugin.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ Call `arkenvPlugin()` with no args, or an options object that includes
4444
transform fields (`schemaPath`, `clientPrefix`, logging). Do not pass a
4545
schema map or compiled `type()` to the plugin.
4646

47+
## Startup validation
48+
49+
When the plugin is registered, it resolves `env.ts` and validates it during
50+
Vite config resolution. Missing or invalid environment variables therefore
51+
abort the dev server or production build before Vite is ready. The plugin
52+
revalidates the schema when a relevant `.env` file or schema module changes
53+
during HMR.
54+
55+
Without the plugin, validation is import-driven and runs when a module imports
56+
`env.ts`. Startup fail-fast is the documented behavior of the existing plugin
57+
contract; there is no default-off lazy-validation flag.
58+
4759
## Next steps
4860

4961
<Cards>

packages/vite-plugin/src/env-module.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,134 @@ describe("transform mode plugin", () => {
170170
}
171171
});
172172

173+
it("fails during config resolution when the environment is invalid", async () => {
174+
const root = mkdtempSync(join(tmpdir(), "arkenv-vite-invalid-startup-"));
175+
temps.push(root);
176+
writeFileSync(
177+
join(root, "env.ts"),
178+
'import arkenv from "@arkenv/core";\n\nexport const env = arkenv({ REQUIRED_TOKEN: "string" });\n',
179+
);
180+
const plugin = arkenvPlugin() as any;
181+
const context = {} as any;
182+
183+
if (plugin.config && typeof plugin.config === "function") {
184+
plugin.config.call(
185+
context,
186+
{ root, envDir: root },
187+
{ mode: "test", command: "serve" },
188+
);
189+
}
190+
191+
await expect(
192+
Promise.resolve().then(() =>
193+
plugin.configResolved?.call(context, {
194+
root,
195+
envDir: root,
196+
envPrefix: "VITE_",
197+
} as any),
198+
),
199+
).rejects.toMatchObject({ name: "ArkEnvError" });
200+
expect(process.env.REQUIRED_TOKEN).toBeUndefined();
201+
});
202+
203+
it("revalidates valid schema and dotenv changes during HMR", async () => {
204+
const root = mkdtempSync(join(tmpdir(), "arkenv-vite-hmr-"));
205+
temps.push(root);
206+
const schemaPath = join(root, "env.ts");
207+
const dotenvPath = join(root, ".env.test");
208+
writeFileSync(
209+
schemaPath,
210+
'import arkenv from "@arkenv/core";\n\nexport const env = arkenv({ VITE_API_URL: "string" });\n',
211+
);
212+
writeFileSync(dotenvPath, "VITE_API_URL=https://example.com\n");
213+
const plugin = arkenvPlugin({ schemaPath }) as any;
214+
const server = {
215+
moduleGraph: {
216+
getModulesByFile: () => new Set([{ id: schemaPath }]),
217+
invalidateModule: () => {},
218+
},
219+
} as any;
220+
const context = {} as any;
221+
222+
plugin.config?.call(
223+
context,
224+
{ root, envDir: root },
225+
{ mode: "test", command: "serve" },
226+
);
227+
await plugin.configResolved?.call(context, {
228+
root,
229+
envDir: root,
230+
envPrefix: "VITE_",
231+
} as any);
232+
233+
const schemaUpdate = plugin.handleHotUpdate?.call(context, {
234+
file: schemaPath,
235+
server,
236+
} as any);
237+
expect(schemaUpdate).toHaveLength(1);
238+
writeFileSync(dotenvPath, "VITE_API_URL=https://updated.example.com\n");
239+
const dotenvUpdate = plugin.handleHotUpdate?.call(context, {
240+
file: dotenvPath,
241+
server,
242+
} as any);
243+
expect(dotenvUpdate).toHaveLength(1);
244+
245+
const clientModule = await plugin.transform?.call(
246+
{
247+
environment: {
248+
name: "client",
249+
config: { consumer: "client" },
250+
},
251+
},
252+
"export const env = {}",
253+
schemaPath,
254+
);
255+
expect(clientModule?.code).toContain(
256+
'"VITE_API_URL": "https://updated.example.com"',
257+
);
258+
});
259+
260+
it("propagates invalid dotenv values during HMR", async () => {
261+
const root = mkdtempSync(join(tmpdir(), "arkenv-vite-invalid-hmr-"));
262+
temps.push(root);
263+
const schemaPath = join(root, "env.ts");
264+
const dotenvPath = join(root, ".env.test");
265+
writeFileSync(
266+
schemaPath,
267+
'import arkenv from "@arkenv/core";\n\nexport const env = arkenv({ VITE_PORT: "number" });\n',
268+
);
269+
writeFileSync(dotenvPath, "VITE_PORT=8080\n");
270+
const plugin = arkenvPlugin({ schemaPath }) as any;
271+
const server = {
272+
moduleGraph: {
273+
getModulesByFile: () => new Set(),
274+
invalidateModule: () => {},
275+
},
276+
} as any;
277+
const context = {} as any;
278+
279+
plugin.config?.call(
280+
context,
281+
{ root, envDir: root },
282+
{ mode: "test", command: "serve" },
283+
);
284+
await plugin.configResolved?.call(context, {
285+
root,
286+
envDir: root,
287+
envPrefix: "VITE_",
288+
} as any);
289+
writeFileSync(dotenvPath, "VITE_PORT=not-a-number\n");
290+
291+
await expect(
292+
Promise.resolve().then(() =>
293+
plugin.handleHotUpdate?.call(context, {
294+
file: dotenvPath,
295+
server,
296+
} as any),
297+
),
298+
).rejects.toMatchObject({ name: "ArkEnvError" });
299+
});
300+
173301
it("passes through the env module unchanged in the SSR graph", async () => {
174302
const fixtureDir = join(__dirname, "__fixtures__", "transform-env");
175303
const plugin = arkenvPlugin({ schemaPath: join(fixtureDir, "env.ts") });

0 commit comments

Comments
 (0)