bini-env 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,25 +6,26 @@
6
6
  ![vite](https://img.shields.io/badge/vite-8.x-646CFF?style=flat-square&logo=vite)
7
7
  ![hono](https://img.shields.io/badge/hono-4.x-E36002?style=flat-square&logo=hono)
8
8
  ![typescript](https://img.shields.io/badge/TypeScript-5.x-3178C6?style=flat-square&logo=typescript)
9
- ![node](https://img.shields.io/badge/node-%3E%3D20.19-339933?style=flat-square&logo=node.js)
9
+ ![node](https://img.shields.io/badge/node-%3E%3D20.12.0-339933?style=flat-square&logo=node.js)
10
10
 
11
- **Environment variable system + Vite plugin for Bini.js**
11
+ Environment variable system + Vite plugin for Bini.js
12
12
 
13
- Powered by [Hono](https://hono.dev) — reads env vars from the Hono request context so variables are always resolved from the correct runtime binding, whether you're on Node.js, Bun, Deno, Vercel Edge, Netlify Edge, or Cloudflare Workers.
13
+ Powered by [Hono](https://hono.dev). When you pass a Hono context, variables are read through Hono's `env(c)` adapter, so they resolve from the runtime binding of the platform you are on, whether that is Node.js, Bun, Deno, Vercel Edge, Netlify Edge, or Cloudflare Workers.
14
14
 
15
15
  ---
16
16
 
17
17
  ## Features
18
18
 
19
- - **Hono-native** — `getEnv(c, key)` / `requireEnv(c, key)` read directly from the Hono request context
20
- - **Universal runtime** — CF Workers, Node, Bun, Deno, Vercel Edge, Netlify Edge — Hono's adapter handles all of it
21
- - **Zero-config dev secrets** — non-prefixed `.env` values are always mirrored into `process.env` automatically during `vite dev` / `vite preview`, so Node-hosted Hono routes can read them with no manual loop
22
- - **Vite-native dev** — `.env` loading, file watching, and server restarts are handled by Vite itself
23
- - **`envDir`-aware** — respects a custom `envDir` or `root` in your `vite.config.ts` instead of always assuming the working directory
24
- - **Dev banner** — prints `ß Bini.js (dev/preview)` and lists detected `.env` files on server start
25
- - **Fixed, predictable prefixes** — only `BINI_` and `VITE_` are ever exposed to the browser; there's no config surface that can accidentally widen it
26
- - **Tree-shakeable** — nothing lands in the client bundle that shouldn't
27
- - **Typed** — full TypeScript support with an exported `HonoContext` type
19
+ - **Hono-native.** `getEnv(c, key)` and `requireEnv(c, key)` read from the Hono request context through `hono/adapter`.
20
+ - **Works without a context too.** Pass a plain object, or `undefined` / `null`, and the functions read from that object or from the runtime's own environment (`process.env`, or `Deno.env`).
21
+ - **Zero-config dev secrets.** During `vite dev` and `vite preview`, `.env` values are mirrored into `process.env`, so Node-hosted Hono routes can read them with no manual loop.
22
+ - **Vite-native loading.** The plugin uses Vite's own `loadEnv`, so the standard `.env`, `.env.local`, `.env.[mode]`, and `.env.[mode].local` files apply.
23
+ - **`envDir`-aware.** A custom `envDir` or `root` in your `vite.config.ts` is respected, and `envDir: false` disables loading.
24
+ - **Public-prefix secret warnings.** A variable whose name looks like a secret but starts with `BINI_` or `VITE_` triggers a warning, because it is bundled into client code.
25
+ - **Dev banner.** Prints `ß Bini.js (dev|preview)` and lists detected `.env` files on server start.
26
+ - **Multi-variable helpers.** `checkEnv` and `requireEnvs` validate several variables at once and report every missing name together.
27
+ - **Edge-safe main entry.** The `bini-env` entry imports nothing from `node:` or `vite`. The Vite plugin lives in `bini-env/vite` and the file loader in `bini-env/node`, and a publish-time check (`check:edge`) verifies that `dist/index.js` has no `node:` or `vite` imports.
28
+ - **Typed.** Full TypeScript support, with exported `HonoContext`, `EnvSource`, `EnvInput`, and `EnvCheck` types.
28
29
 
29
30
  ---
30
31
 
@@ -38,27 +39,35 @@ npm install bini-env hono
38
39
  yarn add bini-env hono
39
40
  ```
40
41
 
41
- > `hono` is a required peer dependency.
42
+ > `hono` (`>=4.0.0`) is a required peer dependency. `vite` (`>=8.0.0`) is an optional peer dependency, needed only for the `bini-env/vite` plugin.
43
+
44
+ ### Entry points
45
+
46
+ | Import | Contents | Runs on |
47
+ | --- | --- | --- |
48
+ | `bini-env` | `getEnv`, `requireEnv`, `checkEnv`, `requireEnvs`, `biniLogger`, and the types | Any runtime (no `node:` or `vite` imports) |
49
+ | `bini-env/vite` | `biniEnv()` | Node, inside Vite |
50
+ | `bini-env/node` | `loadEnvFiles`, `envFilesFor` | Node |
42
51
 
43
52
  ---
44
53
 
45
54
  ## Quick Start
46
55
 
47
- **1. Register the Vite plugin**
56
+ ### 1. Register the Vite plugin
48
57
 
49
58
  ```ts
50
59
  // vite.config.ts
51
60
  import { defineConfig } from 'vite'
52
- import { biniEnv } from 'bini-env'
61
+ import { biniEnv } from 'bini-env/vite'
53
62
 
54
63
  export default defineConfig({
55
64
  plugins: [biniEnv()]
56
65
  })
57
66
  ```
58
67
 
59
- `biniEnv()` takes no options — there's nothing to configure.
68
+ `biniEnv()` takes no options; there is nothing to configure.
60
69
 
61
- **2. Read env vars in your Hono handlers**
70
+ ### 2. Read env vars in your Hono handlers
62
71
 
63
72
  ```ts
64
73
  // src/app/api/hello.ts
@@ -69,17 +78,14 @@ const app = new Hono()
69
78
 
70
79
  app.post('/hello', async (c) => {
71
80
  try {
72
- const ctx = c as any
73
-
74
- // requireEnv throws if the var is missing — fail fast on required config
75
- const apiKey = requireEnv(ctx, 'MY_API_KEY')
81
+ // requireEnv throws if the var is missing or empty: fail fast on required config
82
+ const apiKey = requireEnv(c, 'MY_API_KEY')
76
83
 
77
- // getEnv returns undefined if missing — use ?? to provide a default
78
- const appName = getEnv(ctx, 'APP_NAME') ?? 'World'
79
- const timeout = parseInt(getEnv(ctx, 'TIMEOUT_MS') ?? '5000')
84
+ // getEnv returns undefined if the var is missing: use ?? to provide a default
85
+ const appName = getEnv(c, 'APP_NAME') ?? 'World'
86
+ const timeout = parseInt(getEnv(c, 'TIMEOUT_MS') ?? '5000')
80
87
 
81
88
  return c.json({ message: `Hello, ${appName}!` })
82
-
83
89
  } catch (error: any) {
84
90
  if (error.message?.includes('[bini-env] Missing required')) {
85
91
  return c.json({ error: error.message }, 500)
@@ -91,31 +97,28 @@ app.post('/hello', async (c) => {
91
97
  export default app
92
98
  ```
93
99
 
94
- That's it — no manual `loadEnv` loop in `vite.config.ts` needed. `MY_API_KEY` just needs to exist in your `.env` file with no prefix, and `requireEnv` will find it during dev and preview.
100
+ No manual `loadEnv` loop in `vite.config.ts` is needed. `MY_API_KEY` just needs to exist in your `.env` file, and `requireEnv` will find it during dev and preview.
95
101
 
96
102
  ---
97
103
 
98
104
  ## Usage Pattern
99
105
 
100
- **Always pass `c` explicitly.** Cast it once at the top of the handler, then use `ctx` throughout.
106
+ Pass the Hono context `c` to every call.
101
107
 
102
108
  ```ts
103
109
  app.post('/example', async (c) => {
104
110
  try {
105
- const ctx = c as any
106
-
107
- // Required vars — handler throws immediately if missing
108
- const dbUrl = requireEnv(ctx, 'DATABASE_URL')
109
- const apiKey = requireEnv(ctx, 'STRIPE_SECRET_KEY')
111
+ // Required vars: the handler throws immediately if one is missing or empty
112
+ const dbUrl = requireEnv(c, 'DATABASE_URL')
113
+ const apiKey = requireEnv(c, 'STRIPE_SECRET_KEY')
110
114
 
111
- // Optional vars — fall back to sensible defaults
112
- const model = getEnv(ctx, 'AI_MODEL') ?? 'gpt-4o'
113
- const region = getEnv(ctx, 'AWS_REGION') ?? 'us-east-1'
114
- const maxRetries = parseInt(getEnv(ctx, 'MAX_RETRIES') ?? '3')
115
- const debug = getEnv(ctx, 'DEBUG_MODE') === 'true'
115
+ // Optional vars: fall back to sensible defaults
116
+ const model = getEnv(c, 'AI_MODEL') ?? 'gpt-4o'
117
+ const region = getEnv(c, 'AWS_REGION') ?? 'us-east-1'
118
+ const maxRetries = parseInt(getEnv(c, 'MAX_RETRIES') ?? '3')
119
+ const debug = getEnv(c, 'DEBUG_MODE') === 'true'
116
120
 
117
121
  // ... rest of handler
118
-
119
122
  } catch (error: any) {
120
123
  if (error.message?.includes('[bini-env] Missing required')) {
121
124
  return c.json({ error: error.message }, 500)
@@ -125,56 +128,58 @@ app.post('/example', async (c) => {
125
128
  })
126
129
  ```
127
130
 
128
- **The pattern in three steps:**
131
+ - `requireEnv(c, 'KEY')` for vars the handler cannot run without.
132
+ - `getEnv(c, 'KEY') ?? 'default'` for optional vars with sensible defaults.
133
+ - `checkEnv` and `requireEnvs` for several variables at once (see the [API Reference](#api-reference)).
129
134
 
130
- 1. `const ctx = c as any` — once, at the top of the handler
131
- 2. `requireEnv(ctx, 'KEY')` — for vars the handler cannot run without
132
- 3. `getEnv(ctx, 'KEY') ?? 'default'` — for optional vars with sensible defaults
135
+ > **Empty values.** `getEnv` returns an empty string as-is (`KEY=` yields `''`), so `??` does not fall back to the default for it. `requireEnv` treats an empty value as missing.
133
136
 
134
- ---
137
+ ### Reading without a Hono context
135
138
 
136
- ## How It Works in Dev
139
+ Every function accepts an `EnvInput`: a Hono context, a plain object, or `undefined` / `null`.
140
+
141
+ ```ts
142
+ getEnv(c, 'KEY') // Hono context: read through Hono's env(c) adapter
143
+ getEnv({ KEY: 'value' }, 'KEY') // plain object: read from it directly
144
+ getEnv(undefined, 'KEY') // no source: read from process.env, or Deno.env when there is no process.env
145
+ ```
137
146
 
138
- When you run `vite dev` (or `vite preview`), `biniEnv()` automatically loads your `.env*` files with Vite's own `loadEnv` and mirrors every **non-prefixed** variable into `process.env`. This is what lets `getEnv` / `requireEnv` resolve server-side secrets inside Hono API routes without any extra setup — Hono's `env(c)` adapter reads from `process.env` on Node, and `process.env` is now populated for you.
147
+ When no source is given and `Deno.env` cannot be read (for example without `--allow-env`), the lookup returns `undefined`.
139
148
 
140
- It reads `.env` files from the same place Vite does: your configured `envDir` if you've set one, otherwise your `root`, otherwise the working directory. A custom `envDir` in `vite.config.ts` is respected automatically. If you've set `envDir: false` in your Vite config to disable `.env` loading entirely, `bini-env` respects that too and skips the mirror.
149
+ ---
141
150
 
142
- It happens silently in the background when it succeeds — no terminal log, no banner line, nothing to configure. Your existing dev server output (the `ß Bini.js` banner, detected `.env` files, local/network URLs) stays exactly as it was:
151
+ ## How It Works in Dev
143
152
 
144
- ```
145
- ß Bini.js (dev)
146
- ➜ Environments: .env.local, .env
147
- ➜ Local: http://localhost:3000/
148
- ➜ Network: http://192.168.1.7:3000/
149
- ```
153
+ When you run `vite dev` or `vite preview` (Vite's `serve` command), `biniEnv()` loads your `.env*` files with Vite's own `loadEnv` and mirrors every variable it finds into `process.env`. This is what lets `getEnv` and `requireEnv` resolve server-side secrets inside Hono API routes on Node without any extra setup.
150
154
 
151
- If loading or mirroring the `.env` file fails for any reason, `bini-env` prints a warning — but the dev server still boots either way, it never throws:
155
+ It reads `.env` files from the same place Vite does: the `envDir` you configured (a relative `envDir` is resolved against `root`), otherwise your `root`, otherwise the working directory. If you set `envDir: false` in your Vite config, `bini-env` skips loading and mirroring altogether.
152
156
 
153
- ```
154
- 10:45:55 (!) [bini-env] Failed to inject .env into process.env: <reason>
157
+ The mirror runs once, in the plugin's `config` hook, and prints nothing when it succeeds. If loading fails for any reason, `bini-env` prints a warning and the dev server still boots; it never throws:
158
+
159
+ ```text
160
+ 10:45:55 (!) [bini-env] Failed to load .env files: <reason>
155
161
  ```
156
162
 
157
- This happens automatically — there is no flag to turn it off. It's core behavior of `biniEnv()`, not an opt-in feature.
163
+ This happens automatically; there is no flag to turn it off.
158
164
 
159
- **Precedence:** a value already present in `process.env` (set by your OS, shell, or CI) always wins. `.env` file values only fill in variables that aren't already set — they never override real environment configuration.
165
+ **Precedence:** a value already present in `process.env` (set by your OS, shell, or CI) always wins. `.env` file values only fill in variables that are not already set; they never override real environment configuration.
160
166
 
161
167
  **What gets mirrored:**
162
168
 
163
169
  | Variable | Mirrored into `process.env`? |
164
- |----------|------------------------------|
170
+ | --- | --- |
165
171
  | No prefix (`DATABASE_URL`) | ✅ Yes |
166
- | `BINI_` / `VITE_` prefixed | ❌ No — already handled via `import.meta.env` |
167
- | Empty value (`KEY=`) | ❌ No — skipped, so `requireEnv` still fails loudly on a blank placeholder |
168
- | Already set in the OS/CI environment | ❌ No — existing value is kept |
172
+ | `BINI_` / `VITE_` prefixed | ✅ Yes (they are also exposed through `import.meta.env`) |
173
+ | Empty value (`KEY=`) | ✅ Yes, as an empty string. `requireEnv` still treats it as missing, so a blank placeholder fails loudly |
174
+ | Already set in the OS/CI environment | ❌ No. The existing value is kept |
169
175
 
170
176
  **Scope:**
171
177
 
172
- - Runs only for `vite dev` and `vite preview` (`command === 'serve'`)
173
- - **Never** runs for `vite build` — production bundles and `process.env` at build time are untouched
174
- - **Production** uses your hosting platform's native env injection (`bini-server`, Vercel, Netlify, Cloudflare, etc.) — nothing extra to do there
175
- - This behavior is always on — there is no option to disable it
178
+ - The mirror runs only when Vite's command is `serve` (`vite dev` and `vite preview`).
179
+ - It does **not** run for `vite build`: nothing is written to `process.env` at build time. The build only checks your env files for public-prefixed variables with secret-looking names (see [Security Best Practices](#security-best-practices)).
180
+ - There is no option to disable the mirror.
176
181
 
177
- **Migrating from a manual loop:** if your project already has this in `vite.config.ts`, you can delete it — `biniEnv()` now does it for you:
182
+ **Migrating from a manual loop:** if your project already has this in `vite.config.ts`, you can delete it, because `biniEnv()` now does it for you:
178
183
 
179
184
  ```ts
180
185
  // vite.config.ts — no longer needed, remove this
@@ -193,47 +198,63 @@ export default defineConfig({
193
198
  })
194
199
  ```
195
200
 
201
+ Note that the plugin also mirrors `BINI_` and `VITE_` prefixed variables, which the loop above skipped.
202
+
203
+ ### Dev banner
204
+
205
+ When the dev or preview server starts, `biniEnv()` replaces the URL printout with a version that begins with the `ß Bini.js` banner and the `.env` files it found:
206
+
207
+ ```text
208
+ ß Bini.js (dev)
209
+ ➜ Environments: .env.local, .env
210
+ ➜ Local: http://localhost:3000/
211
+ ➜ Network: http://192.168.1.7:3000/
212
+ ```
213
+
214
+ - The banner lists the files that exist among `.env.[mode].local`, `.env.[mode]`, `.env.local`, and `.env`, looked up in Vite's `root` (the `Environments` line is omitted when none exist). The `process.env` mirror itself uses `envDir`.
215
+ - Vite's `ready in …` line is hidden.
216
+
196
217
  ---
197
218
 
198
219
  ## Environment Prefixes
199
220
 
200
221
  ### The Truth About Prefixes
201
222
 
202
- **Both `BINI_` and `VITE_` work exactly the same way.** Neither is special, and neither can be changed — `biniEnv()` always exposes exactly these two prefixes to the browser, nothing more.
223
+ **Both `BINI_` and `VITE_` work exactly the same way.** `biniEnv()` has no options, so the prefix list is fixed.
203
224
 
204
225
  | Prefix | Exposed to Browser? |
205
- |--------|--------------------|
206
- | `BINI_` | **Yes** — always |
207
- | `VITE_` | **Yes** — always (Vite's own default prefix) |
208
- | No prefix | **No** — mirrored into `process.env` in dev/preview instead |
226
+ | --- | --- |
227
+ | `BINI_` | **Yes** |
228
+ | `VITE_` | **Yes** (Vite's own default prefix) |
229
+ | No prefix | **No** |
209
230
 
210
231
  ### What `biniEnv()` Actually Does
211
232
 
212
233
  ```ts
213
- // biniEnv() always sets envPrefix to:
234
+ // biniEnv() always returns this from its config hook:
214
235
  envPrefix: ['BINI_', 'VITE_']
215
236
 
216
- // This tells Vite: "Expose ALL variables starting with BINI_ or VITE_ to import.meta.env"
217
- // Everything else is mirrored into process.env during vite dev / preview
237
+ // This tells Vite: "Expose variables starting with BINI_ or VITE_ to import.meta.env"
218
238
  ```
219
239
 
220
240
  **This means:**
221
- - ✅ `BINI_API_KEY` → available in browser via `import.meta.env.BINI_API_KEY`
222
- - ✅ `VITE_API_URL` → available in browser via `import.meta.env.VITE_API_URL`
223
- - ❌ `DATABASE_URL` → NOT available in browser (no prefix) — available server-side via `getEnv`/`requireEnv`
241
+
242
+ - ✅ `BINI_API_KEY` → available in the browser via `import.meta.env.BINI_API_KEY`
243
+ - ✅ `VITE_API_URL` → available in the browser via `import.meta.env.VITE_API_URL`
244
+ - ❌ `DATABASE_URL` → not exposed to the browser (no prefix); available server-side via `getEnv` / `requireEnv`
224
245
 
225
246
  ### How to Keep Secrets Secret
226
247
 
227
248
  **Use no prefix for secrets:**
228
249
 
229
250
  ```env
230
- # ✅ NOT exposed to browser — mirrored into process.env for server-side use only
251
+ # ✅ NOT exposed to the browser; mirrored into process.env for server-side use
231
252
  DATABASE_URL=postgres://...
232
253
  STRIPE_SECRET_KEY=sk_live_...
233
254
  AWS_SECRET_KEY=...
234
255
  ```
235
256
 
236
- That's the whole rule. Since the prefix list is fixed, there's no config mistake that can widen what gets exposed — anything without `BINI_` or `VITE_` in front of it simply never reaches the browser.
257
+ Anything without `BINI_` or `VITE_` in front of it is not exposed to the browser.
237
258
 
238
259
  ### Choosing the Right Prefix
239
260
 
@@ -250,102 +271,97 @@ VITE_ANALYTICS_ID=UA-XXXX
250
271
  ### Prefix Summary
251
272
 
252
273
  | Prefix | Exposed to Browser? | Mirrored to `process.env` (dev/preview)? | Use For |
253
- |--------|--------------------|-------------------------------------------|---------|
254
- | No prefix | ❌ Never | ✅ Yes (unless empty or already set) | **SECRETS** — API keys, database URLs, tokens |
255
- | `BINI_` | ✅ Always | ❌ No | Public config |
256
- | `VITE_` | ✅ Always | ❌ No | Public config (Vite's default) |
274
+ | --- | --- | --- | --- |
275
+ | No prefix | ❌ Never | ✅ Yes (unless already set) | **SECRETS**: API keys, database URLs, tokens |
276
+ | `BINI_` | ✅ Yes | ✅ Yes (unless already set) | Public config |
277
+ | `VITE_` | ✅ Yes | ✅ Yes (unless already set) | Public config (Vite's default) |
257
278
 
258
279
  ---
259
280
 
260
281
  ## Platform Support
261
282
 
262
- `getEnv` and `requireEnv` delegate to Hono's `env(c)` adapter, which reads from the correct source on every supported platform automatically. Your code never changes regardless of where it deploys.
283
+ With a Hono context, `getEnv` and `requireEnv` delegate to Hono's `env(c)` adapter, which reads from the correct source on each supported platform. Your code does not change between platforms.
263
284
 
264
285
  | Platform | Runtime | Env source | How Hono reads it |
265
- |----------|---------|------------|-------------------|
266
- | Node.js | Node | System env / platform dashboard / `bini-env` dev mirror | `process.env` ✅ |
286
+ | --- | --- | --- | --- |
287
+ | Node.js | Node | System env / platform dashboard / `bini-env` dev mirror / `.env` files loaded by `bini-server` | `process.env` ✅ |
267
288
  | Bun | Bun | System env / platform dashboard / `bini-env` dev mirror | `process.env` ✅ |
268
289
  | Vercel Edge | V8 isolate | Project settings → Env Vars | `process.env` ✅ |
269
290
  | Netlify Edge | Deno | Site settings → Env Vars | `Deno.env.get()` ✅ |
270
291
  | Cloudflare Workers | V8 isolate | `wrangler.toml` / dashboard | CF bindings via `c.env` ✅ |
271
292
  | Deno Deploy | Deno | Project settings → Env Vars | `Deno.env.get()` ✅ |
272
293
 
273
- > **Cloudflare note:** Secrets set via `wrangler secret put` are only available inside the fetch handler via `c.env` — this is a Workers architecture constraint. `getEnv(ctx, key)` reads them correctly as long as you pass `c`.
294
+ > **Cloudflare note:** secrets set via `wrangler secret put` are only available inside the fetch handler via `c.env`. `getEnv(c, key)` reads them as long as you pass `c`.
295
+
296
+ When you pass anything other than a Hono context, the lookup uses that object, or falls back to `process.env` / `Deno.env` as described in [Reading without a Hono context](#reading-without-a-hono-context).
274
297
 
275
298
  ---
276
299
 
277
300
  ## How It Works
278
301
 
279
- ### The plugin — `biniEnv()`
302
+ ### The plugin: `biniEnv()`
280
303
 
281
- Does three things: tells Vite to expose `BINI_` and `VITE_` prefixed vars to `import.meta.env`, mirrors non-prefixed `.env` values into `process.env` during `vite dev` / `vite preview` so Hono routes can read them, and prints the `ß Bini.js` banner with detected `.env` files when the dev or preview server starts.
304
+ A Vite plugin (name `bini-env`, `enforce: 'pre'`) that does four things:
305
+
306
+ 1. Sets Vite's `envPrefix` to `['BINI_', 'VITE_']`.
307
+ 2. During `vite dev` and `vite preview`, mirrors `.env` values into `process.env` (without overriding existing values).
308
+ 3. Warns about public-prefixed variables with secret-looking names, in dev, preview, and build.
309
+ 4. Prints the `ß Bini.js` banner with detected `.env` files, and hides Vite's `ready in` line.
282
310
 
283
311
  ```ts
284
- // what biniEnv() does internally
312
+ // what biniEnv() does internally (simplified)
285
313
  config(userConfig, { command, mode }) {
286
- if (command === 'serve') {
287
- const envDir = userConfig.envDir ?? userConfig.root ?? process.cwd()
288
- // mirrors non-prefixed, non-empty .env values into process.env
289
- // silent on success, warns on failure — no opt-out
314
+ if (userConfig.envDir !== false) {
315
+ // envDir: a string is resolved against root, otherwise root
316
+ if (command === 'serve') {
317
+ // loadEnv(mode, envDir, '') -> set every key not already in process.env
318
+ } else {
319
+ // loadEnv(mode, envDir, ['BINI_', 'VITE_']) -> secret-name warnings only
320
+ }
321
+ // on error: biniLogger.warn('Failed to load .env files: ...')
290
322
  }
291
323
  return { envPrefix: ['BINI_', 'VITE_'] }
292
324
  }
293
325
  ```
294
326
 
295
- The `process.env` mirror runs silently — it doesn't add any lines to the banner. On server start you will see the same banner as before:
296
-
297
- ```
298
- ß Bini.js (dev)
299
- ➜ Environments: .env.local, .env
300
- ➜ Local: http://localhost:3000/
301
- ➜ Network: http://192.168.1.7:3000/
302
- ```
303
-
304
- Vite handles the rest natively: loading `.env`, `.env.local`, `.env.[mode]` files during dev, watching and restarting on change, injecting prefixed vars into `import.meta.env` at build time, and HMR when env files change.
327
+ ### The env functions: `getEnv` / `requireEnv`
305
328
 
306
- ### The env functions — `getEnv` / `requireEnv`
329
+ With a Hono context, both read through `env(c)` from `hono/adapter`, so every read is request-scoped and resolved by Hono's adapter for the current platform. A value is only returned when it is a string: other binding types, such as Cloudflare KV namespaces, come back as `undefined`. On Node, Hono's adapter reads from `process.env`, which is why the dev-mode mirror makes `.env` values resolvable there.
307
330
 
308
- Both functions read exclusively from `env(c)` via `hono/adapter` — the Hono request context. There is no direct `process.env` access inside `getEnv`/`requireEnv` themselves; on Node, Hono's adapter reads from `process.env`, which is why the dev-mode mirror above is what makes non-prefixed secrets resolvable there. Every read is request-scoped and resolved by Hono's adapter for the current platform.
309
-
310
- `dotenv` is never used. In production, vars are set in your hosting platform's environment config.
331
+ `dotenv` is not used by these modules. In production, set variables in your hosting platform's environment configuration.
311
332
 
312
333
  ---
313
334
 
314
335
  ## API Reference
315
336
 
316
- ### `getEnv(c, key)`
337
+ ### `getEnv(source, key)`
317
338
 
318
- Returns `string | undefined`. Reads from the Hono request context.
339
+ Returns `string | undefined`. `source` is an `EnvInput`: a Hono context, a plain object, or `undefined` / `null`. Values that are not strings return `undefined`; an empty string is returned as-is.
319
340
 
320
341
  ```ts
321
342
  app.get('/config', async (c) => {
322
- const ctx = c as any
323
-
324
- const region = getEnv(ctx, 'AWS_REGION') ?? 'us-east-1'
325
- const logLevel = getEnv(ctx, 'LOG_LEVEL') ?? 'info'
326
- const debug = getEnv(ctx, 'DEBUG_MODE') === 'true'
343
+ const region = getEnv(c, 'AWS_REGION') ?? 'us-east-1'
344
+ const logLevel = getEnv(c, 'LOG_LEVEL') ?? 'info'
345
+ const debug = getEnv(c, 'DEBUG_MODE') === 'true'
327
346
 
328
347
  return c.json({ region, logLevel, debug })
329
348
  })
330
349
  ```
331
350
 
332
- ### `requireEnv(c, key)`
351
+ ### `requireEnv(source, key)`
333
352
 
334
- Returns `string`. Throws immediately if the variable is missing or empty. Logs a descriptive error to the terminal on failure.
353
+ Returns `string`. Throws `Error('[bini-env] Missing required environment variable: "KEY"')` when the variable is missing, empty, or not a string, and logs a descriptive error to the terminal first.
335
354
 
336
355
  ```ts
337
356
  app.post('/send-email', async (c) => {
338
357
  try {
339
- const ctx = c as any
340
-
341
- const smtpHost = requireEnv(ctx, 'SMTP_HOST')
342
- const smtpPass = requireEnv(ctx, 'SMTP_PASS')
343
- const smtpPort = parseInt(getEnv(ctx, 'SMTP_PORT') ?? '587')
358
+ const smtpHost = requireEnv(c, 'SMTP_HOST')
359
+ const smtpPass = requireEnv(c, 'SMTP_PASS')
360
+ const smtpPort = parseInt(getEnv(c, 'SMTP_PORT') ?? '587')
344
361
 
345
362
  // ... send email
346
363
 
347
364
  return c.json({ sent: true })
348
-
349
365
  } catch (error: any) {
350
366
  if (error.message?.includes('[bini-env] Missing required')) {
351
367
  return c.json({ error: error.message }, 500)
@@ -355,48 +371,89 @@ app.post('/send-email', async (c) => {
355
371
  })
356
372
  ```
357
373
 
358
- On failure, the terminal will show:
374
+ On failure, the terminal shows:
359
375
 
376
+ ```text
377
+ 10:45:55 [bini-env] error Missing required environment variable: "SMTP_HOST"
378
+ -> Set it in your hosting dashboard (or in .env for dev, preview and bini-server).
360
379
  ```
361
- [bini-env] error Missing required environment variable: "SMTP_HOST"
362
- -> Set it in your platform's env config or hosting dashboard.
380
+
381
+ ### `checkEnv(source, keys)`
382
+
383
+ Checks several variables at once. Returns `{ ok: boolean, missing: string[] }` (`missing` holds names only, never values). When any are missing, it logs one error listing all the missing names. It does not throw.
384
+
385
+ ```ts
386
+ const { ok, missing } = checkEnv(c, ['DATABASE_URL', 'REDIS_URL', 'SESSION_SECRET'])
387
+ if (!ok) return c.json({ error: `Missing: ${missing.join(', ')}` }, 500)
363
388
  ```
364
389
 
390
+ ### `requireEnvs(source, keys)`
391
+
392
+ Requires several variables at once. Returns a record of key to value. If any are missing or empty, it logs them (through `checkEnv`) and throws one error: `[bini-env] Missing environment variables: A, B`.
393
+
394
+ ```ts
395
+ const { DATABASE_URL, REDIS_URL } = requireEnvs(c, ['DATABASE_URL', 'REDIS_URL'])
396
+ ```
397
+
398
+ ### `loadEnvFiles(root?, mode?)`
399
+
400
+ Imported from `bini-env/node`. Loads `.env` files into `process.env` without Vite; `bini-server` calls it at startup. Defaults: `root` is `process.cwd()`, and `mode` is `process.env.NODE_ENV`, or `'production'` when that is not set. Returns `{ files: string[], injected: number }`.
401
+
402
+ - Files are read in this order of priority: `.env.[mode].local`, `.env.[mode]`, `.env.local`, `.env`. Only files that exist are read.
403
+ - A value is only written when the variable is not already in `process.env`, so existing environment values win, and a variable set by a higher-priority file is not overridden by a lower one.
404
+ - A leading byte-order mark is removed, files are parsed with Node's `util.parseEnv`, and a file that cannot be read or parsed is skipped.
405
+
406
+ ```ts
407
+ import { loadEnvFiles } from 'bini-env/node'
408
+
409
+ const { files, injected } = loadEnvFiles(process.cwd(), 'production')
410
+ ```
411
+
412
+ `envFilesFor(root, mode)` (also from `bini-env/node`) returns the list of existing env file paths in that priority order.
413
+
365
414
  ### `biniEnv()`
366
415
 
367
- Vite plugin. Takes no options. Exposes `BINI_` and `VITE_` prefixed vars to `import.meta.env`, and mirrors every other (non-empty) `.env` value into `process.env` during dev/preview.
416
+ Vite plugin, imported from `bini-env/vite`. Takes no options. Sets `envPrefix` to `['BINI_', 'VITE_']`, mirrors `.env` values into `process.env` during dev and preview, warns about public-prefixed variables with secret-looking names, and prints the dev banner.
368
417
 
369
418
  ```ts
370
419
  biniEnv()
371
420
  ```
372
421
 
373
- There's nothing to configure — no prefix list to extend, no flag to disable the `process.env` mirror. It reads `.env` files from your `envDir` (or `root`, or the working directory), never runs on `vite build`, never overrides a value already set in `process.env`, and skips the mirror entirely if you've set `envDir: false`.
422
+ There is nothing to configure: no prefix list to extend and no flag to disable the `process.env` mirror. It reads `.env` files from your `envDir` (or `root`, or the working directory), never overrides a value already set in `process.env`, never mirrors during `vite build`, and skips loading entirely when `envDir` is `false`.
374
423
 
375
424
  ### `biniLogger`
376
425
 
377
- Vite-style terminal logger. Use it in your own Bini.js plugins or server-side code.
426
+ Terminal logger with Vite-style timestamps. Use it in your own Bini.js plugins or server-side code.
378
427
 
379
428
  ```ts
380
429
  import { biniLogger } from 'bini-env'
381
430
 
382
431
  biniLogger.info('Server ready')
383
432
  biniLogger.warn('Missing optional var')
384
- biniLogger.error('Something broke', error)
433
+ biniLogger.error('Something broke', error) // an Error's message is printed beneath
385
434
  ```
386
435
 
387
- ### `HonoContext`
388
-
389
- Exported type (`Context` from Hono). Use it to type helper functions that group env reads, so you only cast `c as any` once per entry point.
436
+ ### Types
390
437
 
391
438
  ```ts
392
- import type { HonoContext } from 'bini-env'
439
+ import type { HonoContext, EnvSource, EnvInput, EnvCheck } from 'bini-env'
440
+ ```
441
+
442
+ | Type | Definition |
443
+ | --- | --- |
444
+ | `HonoContext` | Hono's `Context` |
445
+ | `EnvSource` | `Record<string, unknown>` |
446
+ | `EnvInput` | `HonoContext \| EnvSource \| undefined \| null` |
447
+ | `EnvCheck` | `{ ok: boolean; missing: string[] }` |
393
448
 
449
+ Use `HonoContext` to type helper functions that group env reads:
450
+
451
+ ```ts
394
452
  function readDbConfig(c: HonoContext) {
395
- const ctx = c as any
396
453
  return {
397
- url: requireEnv(ctx, 'DATABASE_URL'),
398
- poolSize: parseInt(getEnv(ctx, 'DB_POOL_SIZE') ?? '10'),
399
- ssl: getEnv(ctx, 'DB_SSL') !== 'false',
454
+ url: requireEnv(c, 'DATABASE_URL'),
455
+ poolSize: parseInt(getEnv(c, 'DB_POOL_SIZE') ?? '10'),
456
+ ssl: getEnv(c, 'DB_SSL') !== 'false',
400
457
  }
401
458
  }
402
459
  ```
@@ -406,36 +463,47 @@ function readDbConfig(c: HonoContext) {
406
463
  ## Security Best Practices
407
464
 
408
465
  ### Rule 1: Never Prefix Secrets
466
+
409
467
  ```env
410
- # ❌ BAD - This will be exposed to the browser!
468
+ # ❌ BAD: this will be exposed to the browser!
411
469
  BINI_DATABASE_URL=postgres://...
412
470
 
413
- # ✅ GOOD - Not exposed to browser, mirrored into process.env for server-side use
471
+ # ✅ GOOD: not exposed to the browser, mirrored into process.env for server-side use
414
472
  DATABASE_URL=postgres://...
415
473
  ```
416
474
 
475
+ `biniEnv()` helps catch this. When a variable name contains `SECRET`, `PASSWORD`, `PASSWD`, `PRIVATE`, or `TOKEN` (case-insensitive) and starts with `BINI_` or `VITE_`, it prints a warning once per variable, in dev, preview, and build:
476
+
477
+ ```text
478
+ (!) [bini-env] "BINI_API_TOKEN" starts with a public prefix (BINI_ / VITE_) so it is bundled into client code. Rename it without the prefix if it is a secret.
479
+ ```
480
+
481
+ The check is by name only; it cannot tell whether a value is actually sensitive.
482
+
417
483
  ### Rule 2: Use No Prefix for Anything Secret
484
+
418
485
  ```env
419
- # ✅ Safe - Not exposed, available server-side via getEnv/requireEnv
486
+ # ✅ Safe: not exposed, available server-side via getEnv/requireEnv
420
487
  DATABASE_URL=postgres://...
421
488
  STRIPE_SECRET=sk_live_...
422
489
  AWS_SECRET_KEY=...
423
490
  ```
424
491
 
425
492
  ### Rule 3: Use `BINI_` or `VITE_` for Public Data
493
+
426
494
  ```env
427
- # ✅ Safe - Public data
495
+ # ✅ Safe: public data
428
496
  BINI_API_URL=https://api.example.com
429
497
  VITE_GA_ID=UA-XXXXX
430
498
  ```
431
499
 
432
500
  ### Rule 4: Don't Leave Secret Placeholders Empty
433
501
 
434
- An empty value (`API_KEY=`) is skipped by the `process.env` mirror, so `requireEnv` will correctly throw instead of silently succeeding with an empty string. Fill in real values, or remove the line entirely if it's genuinely unused.
502
+ An empty value (`API_KEY=`) is mirrored as an empty string, and `requireEnv` treats it as missing and throws, so a blank placeholder never passes as configured. `getEnv` returns the empty string as-is, so check for it explicitly if you rely on `getEnv`. Fill in real values, or remove the line entirely if it is unused.
435
503
 
436
504
  ### Rule 5: Let OS/CI Env Vars Win in Dev
437
505
 
438
- The `process.env` mirror never overrides a variable that's already set. If you need to override a `.env` value locally (e.g. pointing at a different database), export it in your shell before starting the dev server rather than editing `.env`:
506
+ The `process.env` mirror never overrides a variable that is already set. If you need to override a `.env` value locally (for example to point at a different database), export it in your shell before starting the dev server rather than editing `.env`:
439
507
 
440
508
  ```bash
441
509
  DATABASE_URL=postgres://staging... pnpm dev
@@ -443,15 +511,11 @@ DATABASE_URL=postgres://staging... pnpm dev
443
511
 
444
512
  ---
445
513
 
446
- ## Performance
447
-
448
- | Metric | Dev | Prod |
449
- |--------|-----|------|
450
- | File reads | 1 (`loadEnv`, cached by Vite) | 0 |
451
- | Runtime cost | ~0ms (mirror runs once at server start) | 0 |
452
- | Bundle impact | Minimal | Tree-shaken |
514
+ ## Notes on Cost
453
515
 
454
- No dotenv. No per-request disk reads. No caching layer beyond Vite's own. `getEnv` is a direct call to Hono's adapter on every invocation — request-scoped and correct. The `process.env` mirror runs exactly once, when the dev or preview server starts.
516
+ - `getEnv` and `requireEnv` read no files. With a Hono context, each call goes through Hono's adapter.
517
+ - The `process.env` mirror runs once, in the plugin's `config` hook, when the dev or preview server starts.
518
+ - `loadEnvFiles` reads each existing env file once per call.
455
519
 
456
520
  ---
457
521
 
@@ -459,41 +523,40 @@ No dotenv. No per-request disk reads. No caching layer beyond Vite's own. `getEn
459
523
 
460
524
  ### "My BINI_ var is exposed to the browser!"
461
525
 
462
- Yes — by design. `BINI_` is always exposed, the same as `VITE_`. Use no prefix for secrets.
526
+ Yes, by design. `BINI_` is exposed the same way as `VITE_`. Use no prefix for secrets.
463
527
 
464
528
  ### "Env var is undefined in production"
465
529
 
466
- Vars set in `.env` files are only loaded by Vite (and mirrored into `process.env`) during local development and preview. In production, set your variables in your hosting platform's environment dashboard (Vercel, Netlify, Cloudflare, etc.).
530
+ The `process.env` mirror only runs for `vite dev` and `vite preview`. In production, set your variables in your hosting platform's environment configuration (Vercel, Netlify, Cloudflare, etc.). `bini-server` additionally loads `.env` files from its working directory at startup, and variables already in the environment win.
467
531
 
468
532
  ### "Works in dev, undefined in prod"
469
533
 
470
- Same as above. Local dev works because `biniEnv()` loads `.env` files and mirrors non-prefixed vars into `process.env` automatically. Production requires platform-level configuration.
534
+ Same as above. Local dev works because `biniEnv()` loads `.env` files and mirrors them into `process.env`. Production requires platform-level configuration.
471
535
 
472
536
  ### "My .env value isn't taking effect in dev"
473
537
 
474
- Two likely causes:
475
- - Check your shell and CI environment first — the `process.env` mirror never overrides a variable that's already set there. Unset it in your shell, or override it directly in your shell instead of `.env`.
476
- - Check the value isn't empty (`KEY=`) — empty values are skipped on purpose.
538
+ Check your shell and CI environment first: the `process.env` mirror never overrides a variable that is already set there. Unset it in your shell, or override it directly in your shell instead of in `.env`.
477
539
 
478
540
  ### "requireEnv still throws even though my key is in .env"
479
541
 
480
- If the value is `KEY=` with nothing after the `=`, it's treated as unset and skipped by design — this is intentional so a blank placeholder doesn't silently pass as configured. Give it a real value.
542
+ - If the value is `KEY=` with nothing after the `=`, it is treated as missing by `requireEnv`. Give it a real value.
543
+ - The mirror only runs during `vite dev` and `vite preview`. In a production build, the variable must come from the runtime environment.
481
544
 
482
- ### "bini-env isn't reading my .env file from the right folder"
545
+ ### "getEnv returned an empty string instead of my default"
483
546
 
484
- `biniEnv()` reads from your Vite `envDir` if you've set one, otherwise your `root`, otherwise the working directory — the same resolution Vite itself uses. Double check `envDir`/`root` in `vite.config.ts` if your `.env` lives somewhere non-standard.
547
+ An empty `.env` value is returned as `''`, and `??` only falls back for `undefined`. Use `getEnv(c, 'KEY') || 'default'`, or remove the empty line.
485
548
 
486
- ### "Cloudflare secret not found"
549
+ ### "bini-env isn't reading my .env file from the right folder"
487
550
 
488
- Secrets set via `wrangler secret put` are only available via `c.env` inside a handler — which is exactly what `getEnv(ctx, key)` reads. Ensure you are passing `c` to the function.
551
+ `biniEnv()` reads from your Vite `envDir` if you have set one (relative paths resolve against `root`), otherwise your `root`, otherwise the working directory. Double-check `envDir` and `root` in `vite.config.ts` if your `.env` lives somewhere non-standard. The dev banner lists env files found in `root`, which can differ from `envDir`.
489
552
 
490
- ### "TypeScript error: Context not assignable to HonoContext"
553
+ ### "I set `envDir: false` and nothing loads"
491
554
 
492
- Hono 4.12+ added a symbol to `HonoRequest` that can break structural assignability in strict TypeScript projects. Cast once per handler:
555
+ That is intended: `envDir: false` disables `.env` loading, so `bini-env` skips the mirror and the secret-name check.
493
556
 
494
- ```ts
495
- const ctx = c as any
496
- ```
557
+ ### "Cloudflare secret not found"
558
+
559
+ Secrets set via `wrangler secret put` are only available via `c.env` inside a handler, which is what `getEnv(c, key)` reads. Make sure you pass `c` to the function.
497
560
 
498
561
  ### "Types not found"
499
562
 
@@ -507,12 +570,13 @@ Add to your `tsconfig.json` or entry file:
507
570
 
508
571
  ## Compatibility
509
572
 
510
- | Tool | Version | Notes |
511
- |------|---------|-------|
512
- | Vite | 8.x | `envPrefix` hook and `loadEnv` are fully compatible; empty prefix array is never passed |
513
- | Hono | 4.x | `hono/adapter` `env()` is stable across all 4.x releases |
514
- | TypeScript | 5.x | Full type support via exported `HonoContext` |
515
- | Node.js | ≥ 20.19 | Minimum required version |
573
+ | Tool | Version |
574
+ | --- | --- |
575
+ | Vite | `>=8.0.0` (optional peer; only for `bini-env/vite`) |
576
+ | Hono | `>=4.0.0` (peer) |
577
+ | Node.js | `>=20.12.0` |
578
+
579
+ Typings are included.
516
580
 
517
581
  ---
518
582
 
@@ -524,4 +588,4 @@ PRs are welcome. The goal of this library is to stay minimal and runtime-cost-fr
524
588
 
525
589
  ## License
526
590
 
527
- MIT © Bini.js Team
591
+ MIT © Bini.js Team