|
| 1 | +# Environment Variables |
| 2 | + |
| 3 | +Pracht ships a typed, safe-by-default environment model: server secrets stay on |
| 4 | +the server, client-visible configuration is explicitly opt-in via a naming |
| 5 | +prefix, and the build fails when a non-public variable is referenced in client |
| 6 | +code. |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## The model |
| 11 | + |
| 12 | +| Surface | Import | Contents | Where it works | |
| 13 | +| ----------- | --------------------------- | ------------------------------------- | --------------------- | |
| 14 | +| `serverEnv` | `@pracht/core/env/server` | The full platform env | Server code only | |
| 15 | +| `publicEnv` | `@pracht/core` (any entry) | Only `PRACHT_PUBLIC_`-prefixed vars | Everywhere | |
| 16 | + |
| 17 | +```ts |
| 18 | +// Server code (loaders, middleware, API routes, src/server/**): |
| 19 | +import { serverEnv } from "@pracht/core/env/server"; |
| 20 | +const db = connect(serverEnv.DATABASE_URL); |
| 21 | + |
| 22 | +// Anywhere (values are public, inlined into the client bundle at build time): |
| 23 | +import { publicEnv } from "@pracht/core"; |
| 24 | +const api = publicEnv.PRACHT_PUBLIC_API_BASE; |
| 25 | +``` |
| 26 | + |
| 27 | +### The prefix rule |
| 28 | + |
| 29 | +Only variables prefixed with `PRACHT_PUBLIC_` are exposed through `publicEnv`. |
| 30 | +The pracht Vite plugin adds `PRACHT_PUBLIC_` to Vite's |
| 31 | +[`envPrefix`](https://vite.dev/config/shared-options#envprefix) (alongside the |
| 32 | +default `VITE_`), so prefixed variables are also available directly as |
| 33 | +`import.meta.env.PRACHT_PUBLIC_*` in dev and statically inlined at build time. |
| 34 | +Because values are inlined, never put a secret behind the prefix. |
| 35 | + |
| 36 | +`publicEnv` reads `import.meta.env` when Vite provides it and falls back to |
| 37 | +`process.env` outside Vite (plain Node entries, tests). It is a snapshot of |
| 38 | +build-time values on the client; prefer reading it over `import.meta.env` for |
| 39 | +the typing below. |
| 40 | + |
| 41 | +## Typing your env once |
| 42 | + |
| 43 | +Declare the env shape via the same `Register` declaration-merging pattern used |
| 44 | +for routes and context: |
| 45 | + |
| 46 | +```ts |
| 47 | +// src/env.d.ts |
| 48 | +declare module "@pracht/core" { |
| 49 | + interface Register { |
| 50 | + env: { |
| 51 | + DATABASE_URL: string; |
| 52 | + SESSION_SECRET: string; |
| 53 | + PRACHT_PUBLIC_APP_NAME: string; |
| 54 | + PRACHT_PUBLIC_API_BASE: string; |
| 55 | + }; |
| 56 | + } |
| 57 | +} |
| 58 | +``` |
| 59 | + |
| 60 | +`serverEnv` is then typed as the full shape, and `publicEnv` automatically |
| 61 | +narrows to the `PRACHT_PUBLIC_`-prefixed subset — referencing |
| 62 | +`publicEnv.DATABASE_URL` is a type error. Without a registration both fall back |
| 63 | +to `Record<string, string | undefined>`. |
| 64 | + |
| 65 | +## Per-adapter behavior of `serverEnv` |
| 66 | + |
| 67 | +- **Node** (`@pracht/adapter-node`): resolves to `process.env`. Available at |
| 68 | + module top level. |
| 69 | +- **Vercel** (`@pracht/adapter-vercel`): resolves to `process.env`, which the |
| 70 | + Vercel runtime populates in both Node and edge functions. Available at module |
| 71 | + top level. |
| 72 | +- **Cloudflare** (`@pracht/adapter-cloudflare`): there is no ambient env on |
| 73 | + Workers — bindings arrive per request. The adapter installs the worker `env` |
| 74 | + bindings (via `setServerEnv`) when a request enters the fetch handler, so |
| 75 | + `serverEnv` works inside loaders, middleware, and API routes. It does **not** |
| 76 | + work at module top level (before the first request it throws with a message |
| 77 | + explaining this). Non-string bindings (KV, D1, …) are reachable through |
| 78 | + `serverEnv` too, but `context.env` remains the canonical way to access |
| 79 | + bindings. |
| 80 | + |
| 81 | +Custom setups can call `setServerEnv(env)` (exported from |
| 82 | +`@pracht/core/env/server` and `@pracht/core/server`) to install another source. |
| 83 | + |
| 84 | +## Client-leak detection |
| 85 | + |
| 86 | +During `pracht build` the plugin scans every client chunk for references to |
| 87 | +`process.env.X` / `import.meta.env.X` (including `["X"]` bracket access) where |
| 88 | +`X` is not `PRACHT_PUBLIC_`- or `VITE_`-prefixed and not a Vite built-in |
| 89 | +(`MODE`, `DEV`, `PROD`, `SSR`, `BASE_URL`, plus `NODE_ENV`, which Vite |
| 90 | +statically replaces). |
| 91 | +References are matched both in the rendered chunks and in the transformed |
| 92 | +sources of first-party modules that end up in a chunk — bundlers rewrite |
| 93 | +`process.env` in client output, so the source-level signal is what catches |
| 94 | +most mistakes. A hit fails the build naming the variable, the chunk, and the |
| 95 | +likely source module. |
| 96 | + |
| 97 | +Importing `@pracht/core/env/server` from client code also fails the build |
| 98 | +immediately. Route files may import it freely for `loader`/`headers`/ |
| 99 | +`getStaticPaths` — the client transform strips those exports and the import |
| 100 | +with them (see `docs/ARCHITECTURE.md`, client module transform). |
| 101 | + |
| 102 | +`pracht verify` (and `pracht doctor`) read the build-time env-safety report |
| 103 | +emitted to `dist/client/_pracht/env-safety.json` and also re-run the literal |
| 104 | +chunk scan against an existing `dist/client` output when one is present. |
| 105 | + |
| 106 | +### Escape hatch |
| 107 | + |
| 108 | +Intentional, known-safe references can be allowlisted, or the check disabled: |
| 109 | + |
| 110 | +```ts |
| 111 | +pracht({ |
| 112 | + envSafety: { allow: ["SENTRY_RELEASE"] }, |
| 113 | + // envSafety: false, // disable entirely (not recommended) |
| 114 | +}); |
| 115 | +``` |
| 116 | + |
| 117 | +### Limits |
| 118 | + |
| 119 | +The check detects *references*, not values: a secret returned from a loader |
| 120 | +still reaches the client through hydration state, and a value inlined via a |
| 121 | +custom `define` is invisible to the scan. Use the `audit-secrets` skill for |
| 122 | +dataflow-level review of loader return values. |
0 commit comments