@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6
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 +183 -4
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +6 -387
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -6
- package/dist/ops/index.js +330 -30
- package/dist/ops/index.js.map +1 -1
- package/dist/server/index.js +24 -1
- package/dist/server/index.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
# @spfn/core/env — Environment Variable Management
|
|
2
|
+
|
|
3
|
+
Type-safe, schema-based environment variable validation, parsing, and security-focused
|
|
4
|
+
file separation for Next.js + SPFN server.
|
|
5
|
+
|
|
6
|
+
## Import paths
|
|
7
|
+
|
|
8
|
+
There are **two** entry points. Picking the wrong one breaks the build.
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
// Schema, registry, parsers, type guards — isomorphic (no node:fs)
|
|
12
|
+
import { defineEnvSchema, createEnvRegistry, envString /* ... */ } from '@spfn/core/env';
|
|
13
|
+
|
|
14
|
+
// File loader — SERVER ONLY (uses node:fs). Never import in client/edge code.
|
|
15
|
+
import { loadEnv, loadEnvOnce } from '@spfn/core/env/loader';
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`loadEnv` is **not** re-exported from `@spfn/core/env`. Import it from `@spfn/core/env/loader`.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Public API (complete)
|
|
23
|
+
|
|
24
|
+
From `@spfn/core/env`:
|
|
25
|
+
|
|
26
|
+
- Schema helpers: `defineEnvSchema`, `envString`, `envNumber`, `envBoolean`, `envUrl`,
|
|
27
|
+
`envEnum`, `envJson`
|
|
28
|
+
- Registry: `createEnvRegistry`, `EnvRegistry` (class), `validateAllEnv`
|
|
29
|
+
- Type guards: `isClientAccessible`, `isServerOnly`, `isNextjsAccessible`, `isSpfnServerOnly`
|
|
30
|
+
- Parsers: `parseString`, `createStringParser`, `parseBoolean`, `parseNumber`,
|
|
31
|
+
`createNumberParser`, `parseInteger`, `parseDecimal`, `parseUrl`, `createUrlParser`,
|
|
32
|
+
`parsePostgresUrl`, `parseRedisUrl`, `parseEnum`, `createEnumParser`, `parseJson`,
|
|
33
|
+
`createJsonParser`, `parseArray`, `createArrayParser`, `createSecureSecretParser`,
|
|
34
|
+
`createPasswordParser`
|
|
35
|
+
- Parser composition: `chain`, `withFallback`, `optional`
|
|
36
|
+
- Types: `Parser<T>`, `EnvVarSchema`, `EnvSchemaCollection`, `InferEnvType`,
|
|
37
|
+
`EnvValidationResult`, `NodeEnv`, `LogLevel`
|
|
38
|
+
|
|
39
|
+
From `@spfn/core/env/loader`:
|
|
40
|
+
|
|
41
|
+
- `loadEnv`, `loadEnvOnce`, `resetEnvLoadState`
|
|
42
|
+
- Types: `LoadEnvOptions`, `LoadEnvResult`
|
|
43
|
+
|
|
44
|
+
> There is **no** `getEnvVar` / `requireEnvVar` / `hasEnvVar` / `getEnvVars` /
|
|
45
|
+
> `loadEnvironment` function, and **no** `env.get()` / `env.require()` /
|
|
46
|
+
> `getByCategory()` / `generateMarkdownDocs()` etc. Those belong to a removed API — do
|
|
47
|
+
> not use them. The current model is: define a schema, build a registry, call
|
|
48
|
+
> `.validate()`, read properties off the returned proxy.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
// src/config/env.ts
|
|
56
|
+
import {
|
|
57
|
+
defineEnvSchema,
|
|
58
|
+
envString,
|
|
59
|
+
envNumber,
|
|
60
|
+
envBoolean,
|
|
61
|
+
envEnum,
|
|
62
|
+
createEnvRegistry,
|
|
63
|
+
parsePostgresUrl,
|
|
64
|
+
} from '@spfn/core/env';
|
|
65
|
+
|
|
66
|
+
const schema = defineEnvSchema({
|
|
67
|
+
DATABASE_URL: envString({
|
|
68
|
+
description: 'PostgreSQL connection URL',
|
|
69
|
+
required: true,
|
|
70
|
+
sensitive: true,
|
|
71
|
+
validator: parsePostgresUrl,
|
|
72
|
+
}),
|
|
73
|
+
PORT: envNumber({
|
|
74
|
+
description: 'Server port',
|
|
75
|
+
default: 3000,
|
|
76
|
+
}),
|
|
77
|
+
DEBUG: envBoolean({
|
|
78
|
+
description: 'Enable debug mode',
|
|
79
|
+
default: false,
|
|
80
|
+
}),
|
|
81
|
+
LOG_LEVEL: envEnum(['debug', 'info', 'warn', 'error'] as const, {
|
|
82
|
+
description: 'Logging level',
|
|
83
|
+
default: 'info',
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const registry = createEnvRegistry(schema);
|
|
88
|
+
export const env = registry.validate();
|
|
89
|
+
export type Env = typeof env;
|
|
90
|
+
|
|
91
|
+
// Full type safety:
|
|
92
|
+
env.DATABASE_URL; // string (required)
|
|
93
|
+
env.PORT; // number (default: 3000)
|
|
94
|
+
env.DEBUG; // boolean (default: false)
|
|
95
|
+
env.LOG_LEVEL; // 'debug' | 'info' | 'warn' | 'error'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`defineEnvSchema` auto-fills the `key` field from each object key. You can write the
|
|
99
|
+
schema object inline without it, but then you must set `key` manually on every entry —
|
|
100
|
+
prefer `defineEnvSchema`.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Schema Definition
|
|
105
|
+
|
|
106
|
+
### Type helpers
|
|
107
|
+
|
|
108
|
+
| Helper | Result type | Default validator |
|
|
109
|
+
|--------|-------------|-------------------|
|
|
110
|
+
| `envString(options)` | `string` | identity (no-op) |
|
|
111
|
+
| `envNumber(options)` | `number` | `parseNumber` |
|
|
112
|
+
| `envBoolean(options)` | `boolean` | `parseBoolean` |
|
|
113
|
+
| `envUrl(options)` | `string` | none (set `validator` yourself, e.g. `parsePostgresUrl`) |
|
|
114
|
+
| `envEnum(allowed, options)` | union of `allowed` | built-in membership check |
|
|
115
|
+
| `envJson<T>(options)` | `T` | `parseJson` |
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
API_KEY: envString({
|
|
119
|
+
description: 'API authentication key',
|
|
120
|
+
required: true,
|
|
121
|
+
sensitive: true,
|
|
122
|
+
minLength: 32,
|
|
123
|
+
}),
|
|
124
|
+
|
|
125
|
+
PORT: envNumber({
|
|
126
|
+
description: 'Server port',
|
|
127
|
+
default: 3000,
|
|
128
|
+
validator: createNumberParser({ min: 1, max: 65535, integer: true }),
|
|
129
|
+
}),
|
|
130
|
+
|
|
131
|
+
// envUrl has NO default validator — add one for protocol enforcement:
|
|
132
|
+
API_URL: envUrl({
|
|
133
|
+
description: 'API endpoint URL',
|
|
134
|
+
required: true,
|
|
135
|
+
validator: createUrlParser('https'),
|
|
136
|
+
}),
|
|
137
|
+
|
|
138
|
+
LOG_LEVEL: envEnum(['debug', 'info', 'warn', 'error'] as const, {
|
|
139
|
+
description: 'Logging level',
|
|
140
|
+
default: 'info',
|
|
141
|
+
}),
|
|
142
|
+
|
|
143
|
+
CONFIG: envJson<{ host: string; port: number }>({
|
|
144
|
+
description: 'JSON configuration',
|
|
145
|
+
required: true,
|
|
146
|
+
}),
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `EnvVarSchema` options
|
|
150
|
+
|
|
151
|
+
| Option | Type | Description |
|
|
152
|
+
|--------|------|-------------|
|
|
153
|
+
| `description` | `string` | Purpose / usage (used in CLI validate output) |
|
|
154
|
+
| `type` | `'string' \| 'number' \| 'boolean' \| 'url' \| 'enum' \| 'json'` | Set by the helper; don't pass manually |
|
|
155
|
+
| `required` | `boolean` | Throw at access time if unset and no default |
|
|
156
|
+
| `default` | `T` | Value returned when the variable is unset |
|
|
157
|
+
| `validator` | `(value: string) => T` | Parse/validate. Throwing fails validation |
|
|
158
|
+
| `fallbackKeys` | `string[]` | Legacy keys read (in order) when the primary key is unset |
|
|
159
|
+
| `minLength` | `number` | Minimum raw string length (checked before `validator`) |
|
|
160
|
+
| `sensitive` | `boolean` | Marks a secret; triggers a warning if `NEXT_PUBLIC_*` |
|
|
161
|
+
| `examples` | `T[]` | Example values (metadata only) |
|
|
162
|
+
| `nextjs` | `boolean` | File-separation hint (see below). Defaults to `true` for `NEXT_PUBLIC_*`, else `false` |
|
|
163
|
+
|
|
164
|
+
> There is **no** `category` option. Docs that show `category: '...'` are stale — passing
|
|
165
|
+
> it is harmless (excess property) but it does nothing.
|
|
166
|
+
|
|
167
|
+
### Type inference (`InferEnvType`)
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import type { InferEnvType } from '@spfn/core/env';
|
|
171
|
+
|
|
172
|
+
const schema = defineEnvSchema({
|
|
173
|
+
DATABASE_URL: envString({ description: 'DB URL', required: true }),
|
|
174
|
+
PORT: envNumber({ description: 'Port', default: 3000 }),
|
|
175
|
+
DEBUG: envBoolean({ description: 'Debug' }),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
type Env = InferEnvType<typeof schema>;
|
|
179
|
+
// {
|
|
180
|
+
// DATABASE_URL: string; // required: true → required
|
|
181
|
+
// PORT: number; // has default → required
|
|
182
|
+
// DEBUG?: boolean | undefined; // neither → optional
|
|
183
|
+
// }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Rule: `required: true` **or** a `default` present ⇒ required field. Otherwise optional
|
|
187
|
+
(`| undefined`). `registry.validate()` returns `InferEnvType<typeof schema>`.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Registry
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import { createEnvRegistry } from '@spfn/core/env';
|
|
195
|
+
|
|
196
|
+
const registry = createEnvRegistry(schema); // or: new EnvRegistry(schema)
|
|
197
|
+
const env = registry.validate();
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Lazy validation (timing matters)
|
|
201
|
+
|
|
202
|
+
`registry.validate()` performs **only schema-level checks** (e.g. sensitive + client-
|
|
203
|
+
accessible warnings) and returns a **Proxy**. Each individual variable is read from
|
|
204
|
+
`process.env`, defaulted, and run through its `validator` **at the moment you access the
|
|
205
|
+
property** — not when `validate()` is called.
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
const env = registry.validate(); // does NOT read process.env values yet
|
|
209
|
+
loadEnv(); // dotenv files populate process.env
|
|
210
|
+
env.DATABASE_URL; // value is read + validated HERE
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Consequences:
|
|
214
|
+
|
|
215
|
+
- You can call `validate()` before `loadEnv()` — values are picked up lazily. This is the
|
|
216
|
+
intended order for the SPFN server entry point.
|
|
217
|
+
- A missing `required` variable does **not** throw at `validate()` time; it throws on
|
|
218
|
+
first access (`Error('Environment validation failed')`, with the offending key logged).
|
|
219
|
+
- Accessing an unknown key (not in the schema) returns `undefined`.
|
|
220
|
+
|
|
221
|
+
### Skipping validation
|
|
222
|
+
|
|
223
|
+
Set `SKIP_ENV_VALIDATION=true` (or `=1`) to make the proxy skip the `required` check —
|
|
224
|
+
missing required vars return their `default` (or `undefined`) instead of throwing. Useful
|
|
225
|
+
for build steps that don't have secrets. `validator`/`minLength` checks still apply when a
|
|
226
|
+
value is present. **`EnvRegistry.validateAll()` ignores this flag** and always reports
|
|
227
|
+
missing requireds.
|
|
228
|
+
|
|
229
|
+
### `validateAll()` / `validateAllEnv()` (eager, for CLI)
|
|
230
|
+
|
|
231
|
+
For an explicit up-front check of *all* variables (used by `spfn env validate`):
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
import { validateAllEnv } from '@spfn/core/env';
|
|
235
|
+
|
|
236
|
+
const result = validateAllEnv([coreRegistry, authRegistry]); // EnvValidationResult
|
|
237
|
+
if (!result.valid) {
|
|
238
|
+
for (const e of result.errors) {
|
|
239
|
+
console.error(`${e.key}: ${e.message}`);
|
|
240
|
+
}
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
for (const w of result.warnings) {
|
|
244
|
+
console.warn(`${w.key}: ${w.message}`);
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`EnvValidationResult` is `{ valid: boolean; errors: {key,message}[]; warnings: {key,message}[] }`.
|
|
249
|
+
A single registry exposes the same via `registry.validateAll()` returning just
|
|
250
|
+
`{ errors, warnings }`.
|
|
251
|
+
|
|
252
|
+
### Fallback keys
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
DATABASE_URL: envString({
|
|
256
|
+
description: 'Database URL',
|
|
257
|
+
required: true,
|
|
258
|
+
fallbackKeys: ['DB_URL', 'POSTGRES_URL'], // tried in order if DATABASE_URL is unset
|
|
259
|
+
}),
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Other `EnvRegistry` methods
|
|
263
|
+
|
|
264
|
+
`register(schema)`, `registerMultiple(schemas)` — add schemas after construction.
|
|
265
|
+
`reset()` — clears the internal "already validated" flag (test helper).
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Environment File Loading
|
|
270
|
+
|
|
271
|
+
`loadEnv` is server-only (`@spfn/core/env/loader`). It parses `.env*` files, merges them,
|
|
272
|
+
and writes into `process.env`.
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
import { loadEnv } from '@spfn/core/env/loader';
|
|
276
|
+
import { createEnvRegistry } from '@spfn/core/env';
|
|
277
|
+
import { envSchema } from './env.schema';
|
|
278
|
+
|
|
279
|
+
loadEnv(); // server entry point
|
|
280
|
+
const env = createEnvRegistry(envSchema).validate();
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Loading priority (5 tiers, low → high; later overrides earlier)
|
|
284
|
+
|
|
285
|
+
`NODE_ENV` (default `'local'`) selects which files apply:
|
|
286
|
+
|
|
287
|
+
1. `.env` — common defaults (committed)
|
|
288
|
+
2. `.env.{NODE_ENV}` — per-environment override (committed)
|
|
289
|
+
3. `.env.local` — local override (gitignored; **skipped when `NODE_ENV=test`**)
|
|
290
|
+
4. `.env.{NODE_ENV}.local` — per-environment secrets (gitignored)
|
|
291
|
+
5. `.env.server` — SPFN-server-only secrets (gitignored; **never read by Next.js**)
|
|
292
|
+
|
|
293
|
+
Within `loadEnv`, **keys already present in `process.env` are not overwritten** (so
|
|
294
|
+
platform-injected vars win) unless `override: true`.
|
|
295
|
+
|
|
296
|
+
### `LoadEnvOptions`
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
loadEnv({
|
|
300
|
+
cwd: '/path/to/project', // project root (default: process.cwd())
|
|
301
|
+
nodeEnv: 'production', // selects .env.{nodeEnv} (default: process.env.NODE_ENV || 'local')
|
|
302
|
+
server: true, // include .env.server (default: true; set false for Next.js client builds)
|
|
303
|
+
debug: true, // log loaded files (default: false)
|
|
304
|
+
override: false, // overwrite existing process.env keys (default: false)
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`loadEnv` returns `LoadEnvResult` = `{ loadedFiles: string[]; loadedKeys: string[] }`.
|
|
309
|
+
|
|
310
|
+
### `loadEnvOnce()` / `resetEnvLoadState()`
|
|
311
|
+
|
|
312
|
+
`loadEnvOnce(options?)` runs `loadEnv` once per process; subsequent calls return an empty
|
|
313
|
+
result. `resetEnvLoadState()` clears that latch (test helper).
|
|
314
|
+
|
|
315
|
+
### Next.js does its own loading
|
|
316
|
+
|
|
317
|
+
Next.js loads `.env` / `.env.local` / `.env.{NODE_ENV}` itself. In Next.js code (server
|
|
318
|
+
components, route handlers) **do not call `loadEnv()`** — just build the registry and read
|
|
319
|
+
values. `.env.server` is intentionally invisible to Next.js (that is the whole point of
|
|
320
|
+
the separation below).
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Security Separation (Next.js + SPFN)
|
|
325
|
+
|
|
326
|
+
SPFN runs as a **separate process** from Next.js. Next.js server components can read every
|
|
327
|
+
variable in `.env` / `.env.local`; a server-side data-exfiltration bug (e.g. a
|
|
328
|
+
react2shell-style vulnerability) would expose all of them. Keep server-only secrets out of
|
|
329
|
+
files Next.js reads.
|
|
330
|
+
|
|
331
|
+
### File layout
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
project/
|
|
335
|
+
├── .env # common defaults (committed)
|
|
336
|
+
├── .env.production # production non-secret overrides (committed)
|
|
337
|
+
├── .env.local # Next.js local overrides (gitignored)
|
|
338
|
+
├── .env.production.local # production secrets (gitignored)
|
|
339
|
+
└── .env.server # SPFN-server-only secrets (gitignored)
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Commit a `.env.server.example` template (placeholder values only) so teammates know which
|
|
343
|
+
keys to fill in.
|
|
344
|
+
|
|
345
|
+
### Which file for what?
|
|
346
|
+
|
|
347
|
+
| Variable | File | Why |
|
|
348
|
+
|----------|------|-----|
|
|
349
|
+
| `NEXT_PUBLIC_*` | `.env.local` | Client-exposed; browser-safe |
|
|
350
|
+
| `SPFN_API_URL` (per-env) | `.env` / `.env.production` | Used by Next.js too; non-secret |
|
|
351
|
+
| `SPFN_APP_URL` | `.env.local` | Next.js local setting |
|
|
352
|
+
| `DB_POOL_MAX` | `.env.server` | Server-only, non-secret |
|
|
353
|
+
| `DATABASE_URL` | `.env.server` | Server-only, **secret** |
|
|
354
|
+
| `SESSION_SECRET` | `.env.server` | Server-only, **secret** |
|
|
355
|
+
|
|
356
|
+
Rule of thumb: anything Next.js must read goes in `.env` / `.env.local`; everything
|
|
357
|
+
server-only (especially secrets) goes in `.env.server`.
|
|
358
|
+
|
|
359
|
+
### Schema `nextjs` flag
|
|
360
|
+
|
|
361
|
+
`nextjs` documents which process a variable belongs to. Defaults: `true` for
|
|
362
|
+
`NEXT_PUBLIC_*`, `false` otherwise. The type guards below consume it.
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
DATABASE_URL: envString({
|
|
366
|
+
description: 'PostgreSQL connection URL',
|
|
367
|
+
required: true,
|
|
368
|
+
sensitive: true,
|
|
369
|
+
nextjs: false, // SPFN-server-only → belongs in .env.server
|
|
370
|
+
}),
|
|
371
|
+
|
|
372
|
+
SPFN_API_URL: envString({
|
|
373
|
+
description: 'Backend API URL',
|
|
374
|
+
required: true,
|
|
375
|
+
nextjs: true, // Next.js also reads it → .env / .env.local
|
|
376
|
+
}),
|
|
377
|
+
|
|
378
|
+
NEXT_PUBLIC_WS_URL: envString({
|
|
379
|
+
description: 'WebSocket URL',
|
|
380
|
+
// nextjs defaults to true (NEXT_PUBLIC_ prefix)
|
|
381
|
+
}),
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Type guards
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
import {
|
|
388
|
+
isClientAccessible, isServerOnly,
|
|
389
|
+
isNextjsAccessible, isSpfnServerOnly,
|
|
390
|
+
} from '@spfn/core/env';
|
|
391
|
+
|
|
392
|
+
isClientAccessible('NEXT_PUBLIC_API_URL'); // true — string key, NEXT_PUBLIC_ prefix
|
|
393
|
+
isClientAccessible('DATABASE_URL'); // false
|
|
394
|
+
isServerOnly('DATABASE_URL'); // true — inverse of isClientAccessible
|
|
395
|
+
|
|
396
|
+
// These take a SCHEMA object (not a string) and honor the `nextjs` flag:
|
|
397
|
+
isNextjsAccessible(schema.SPFN_API_URL); // true
|
|
398
|
+
isSpfnServerOnly(schema.DATABASE_URL); // true — inverse of isNextjsAccessible
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
> `isClientAccessible` / `isServerOnly` take a **string key**.
|
|
402
|
+
> `isNextjsAccessible` / `isSpfnServerOnly` take an **`EnvVarSchema` object** and respect
|
|
403
|
+
> its `nextjs` flag. Don't mix them up.
|
|
404
|
+
|
|
405
|
+
### Sensitive-on-client warning
|
|
406
|
+
|
|
407
|
+
`registry.validate()` (and `validateAll()`) emit a warning if a `sensitive` variable is
|
|
408
|
+
client-accessible — fix it by dropping the `NEXT_PUBLIC_` prefix, not by unmarking it:
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
// Wrong — secret exposed to the browser, triggers a warning:
|
|
412
|
+
NEXT_PUBLIC_API_SECRET: envString({ description: 'secret', sensitive: true }),
|
|
413
|
+
|
|
414
|
+
// Right:
|
|
415
|
+
API_SECRET: envString({ description: 'secret', sensitive: true }),
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Parsers
|
|
421
|
+
|
|
422
|
+
All parsers are `(value: string) => T` and **throw** on invalid input. Use them as a
|
|
423
|
+
schema `validator`, or standalone.
|
|
424
|
+
|
|
425
|
+
### String
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
import { parseString, createStringParser } from '@spfn/core/env';
|
|
429
|
+
|
|
430
|
+
parseString(' hello '); // 'hello' (trims; throws if empty)
|
|
431
|
+
|
|
432
|
+
createStringParser({ minLength: 32, maxLength: 128, pattern: /^[A-Za-z0-9_-]+$/, trim: true });
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Boolean
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
import { parseBoolean } from '@spfn/core/env';
|
|
439
|
+
|
|
440
|
+
parseBoolean('true' /* | '1' | 'yes' */); // true (case-insensitive)
|
|
441
|
+
parseBoolean('false' /* | '0' | 'no' */); // false
|
|
442
|
+
// anything else → throws
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### Number
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
import {
|
|
449
|
+
parseNumber, createNumberParser, parseInteger, parseDecimal,
|
|
450
|
+
} from '@spfn/core/env';
|
|
451
|
+
|
|
452
|
+
parseNumber('42'); // 42
|
|
453
|
+
parseNumber('42', { min: 1, max: 100, integer: true });
|
|
454
|
+
createNumberParser({ min: 1, max: 65535, integer: true });
|
|
455
|
+
parseInteger('42', { min: 1, max: 100 }); // integer-constrained
|
|
456
|
+
parseDecimal('0.75', { min: 0, max: 1 }); // float-constrained
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
> The float helper is named **`parseDecimal`**. There is no `parseFloat` export.
|
|
460
|
+
|
|
461
|
+
### URL
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
import {
|
|
465
|
+
parseUrl, createUrlParser, parsePostgresUrl, parseRedisUrl,
|
|
466
|
+
} from '@spfn/core/env';
|
|
467
|
+
|
|
468
|
+
parseUrl('https://api.example.com'); // any protocol
|
|
469
|
+
parseUrl('https://x', { protocol: 'https' }); // enforce protocol
|
|
470
|
+
createUrlParser('https'); // reusable
|
|
471
|
+
parsePostgresUrl('postgres://u:p@host:5432/db'); // postgres:// or postgresql://
|
|
472
|
+
parseRedisUrl('redis://localhost:6379'); // redis:// or rediss://
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Enum
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
import { parseEnum, createEnumParser } from '@spfn/core/env';
|
|
479
|
+
|
|
480
|
+
parseEnum('info', ['debug', 'info', 'warn', 'error']);
|
|
481
|
+
createEnumParser(['debug', 'info', 'warn', 'error'], true /* caseInsensitive */);
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
> For schema fields prefer the `envEnum(allowed, options)` helper — it produces a precise
|
|
485
|
+
> string-literal union type. `parseEnum`/`createEnumParser` return a plain `string`.
|
|
486
|
+
|
|
487
|
+
### JSON
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
import { parseJson, createJsonParser } from '@spfn/core/env';
|
|
491
|
+
|
|
492
|
+
parseJson('{"host":"localhost","port":3000}');
|
|
493
|
+
createJsonParser<{ host: string; port: number }>();
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### Array
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
import { parseArray, createArrayParser, createNumberParser } from '@spfn/core/env';
|
|
500
|
+
|
|
501
|
+
parseArray('a,b,c'); // ['a','b','c']
|
|
502
|
+
parseArray('a|b|c', { separator: '|' }); // ['a','b','c'] (empty string → [])
|
|
503
|
+
|
|
504
|
+
createArrayParser(createNumberParser({ min: 1, max: 65535, integer: true }))('3000,4000');
|
|
505
|
+
// → [3000, 4000]
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### Secrets & passwords
|
|
509
|
+
|
|
510
|
+
```typescript
|
|
511
|
+
import { createSecureSecretParser, createPasswordParser } from '@spfn/core/env';
|
|
512
|
+
|
|
513
|
+
// Entropy-based secret check (Shannon entropy in bits/char)
|
|
514
|
+
createSecureSecretParser({ minLength: 32, minUniqueChars: 16, minEntropy: 3.5 });
|
|
515
|
+
// reference: random lowercase ~4.7 · alphanumeric ~5.2 · printable ASCII ~6.6 · "aaaa" ~0
|
|
516
|
+
|
|
517
|
+
createPasswordParser({
|
|
518
|
+
minLength: 12,
|
|
519
|
+
requireUppercase: true,
|
|
520
|
+
requireLowercase: true,
|
|
521
|
+
requireNumber: true,
|
|
522
|
+
requireSpecial: true,
|
|
523
|
+
});
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Composition
|
|
527
|
+
|
|
528
|
+
```typescript
|
|
529
|
+
import { chain, withFallback, optional, parseString, createStringParser, parseRedisUrl, parseJson } from '@spfn/core/env';
|
|
530
|
+
|
|
531
|
+
chain(parseString, createStringParser({ minLength: 32 })); // run sequentially
|
|
532
|
+
withFallback(parseJson, { host: 'localhost' }); // return fallback if parser throws
|
|
533
|
+
optional(parseRedisUrl); // '' → undefined, else parse
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## Pitfalls & anti-patterns
|
|
539
|
+
|
|
540
|
+
- **Don't import `loadEnv` from `@spfn/core/env`.** It lives in `@spfn/core/env/loader`
|
|
541
|
+
(server-only, uses `node:fs`). Importing the loader into client/edge code breaks bundling.
|
|
542
|
+
- **`.env.server.local` does not exist.** The loader's 5 tiers are `.env`,
|
|
543
|
+
`.env.{NODE_ENV}`, `.env.local`, `.env.{NODE_ENV}.local`, `.env.server`. Do not invent a
|
|
544
|
+
`.env.server.local` file — it is never loaded.
|
|
545
|
+
- **`.env.server` is gitignored and Next.js-invisible by design.** Commit
|
|
546
|
+
`.env.server.example` as the template; never put secrets in `.env`/`.env.local`/committed
|
|
547
|
+
files.
|
|
548
|
+
- **Don't call `loadEnv()` inside Next.js.** Next.js already loads `.env*` (minus
|
|
549
|
+
`.env.server`). Calling `loadEnv` there is redundant and, with `server: true`, would pull
|
|
550
|
+
server secrets into the Next.js process — defeating the separation.
|
|
551
|
+
- **`validate()` is lazy.** It does not throw for missing required vars; the throw happens
|
|
552
|
+
on first property access. For an eager all-at-once check (CI/startup gate) use
|
|
553
|
+
`validateAllEnv([...])` / `registry.validateAll()`.
|
|
554
|
+
- **`envUrl` has no built-in validator.** Without `validator`, it only enforces presence —
|
|
555
|
+
add `parsePostgresUrl` / `createUrlParser('https')` etc. for actual URL validation.
|
|
556
|
+
- **`sensitive: true` + `NEXT_PUBLIC_` is wrong.** It only emits a warning, not an error —
|
|
557
|
+
the secret is still exposed. Remove the prefix.
|
|
558
|
+
- **`SKIP_ENV_VALIDATION` only skips the `required` check** (and only in the lazy proxy);
|
|
559
|
+
present values are still validated, and `validateAll()` ignores the flag entirely.
|
|
560
|
+
- **Removed API.** `getEnvVar`, `requireEnvVar`, `hasEnvVar`, `getEnvVars`,
|
|
561
|
+
`loadEnvironment`, `namespace`/`useFolderStructure`/`customPaths`/`useCache` loader
|
|
562
|
+
options, `env.get()`/`env.require()`, `getByCategory()`/`getAllSchemas()`,
|
|
563
|
+
`generateMarkdownDocs`/`generateEnvExample`/`generateJsonDocs`, and the `category` schema
|
|
564
|
+
option **do not exist**. Some validator JSDoc still shows `getEnvVar(...)` in examples —
|
|
565
|
+
that is stale; use the schema-`validator` pattern instead.
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
## Complete example
|
|
570
|
+
|
|
571
|
+
```typescript
|
|
572
|
+
// src/config/env.ts
|
|
573
|
+
import {
|
|
574
|
+
defineEnvSchema,
|
|
575
|
+
envString, envNumber, envBoolean, envEnum, envUrl,
|
|
576
|
+
createEnvRegistry,
|
|
577
|
+
parsePostgresUrl, createNumberParser, createSecureSecretParser, createUrlParser,
|
|
578
|
+
} from '@spfn/core/env';
|
|
579
|
+
|
|
580
|
+
const schema = defineEnvSchema({
|
|
581
|
+
DATABASE_URL: envString({
|
|
582
|
+
description: 'PostgreSQL connection URL',
|
|
583
|
+
required: true,
|
|
584
|
+
sensitive: true,
|
|
585
|
+
nextjs: false,
|
|
586
|
+
validator: parsePostgresUrl,
|
|
587
|
+
}),
|
|
588
|
+
PORT: envNumber({
|
|
589
|
+
description: 'Server port',
|
|
590
|
+
default: 3000,
|
|
591
|
+
validator: createNumberParser({ min: 1, max: 65535, integer: true }),
|
|
592
|
+
}),
|
|
593
|
+
HOST: envString({ description: 'Server host', default: '0.0.0.0' }),
|
|
594
|
+
SESSION_SECRET: envString({
|
|
595
|
+
description: 'Session encryption secret',
|
|
596
|
+
required: true,
|
|
597
|
+
sensitive: true,
|
|
598
|
+
nextjs: false,
|
|
599
|
+
validator: createSecureSecretParser({ minLength: 32 }),
|
|
600
|
+
}),
|
|
601
|
+
NODE_ENV: envEnum(['local', 'development', 'staging', 'production', 'test'] as const, {
|
|
602
|
+
description: 'Node environment',
|
|
603
|
+
default: 'local',
|
|
604
|
+
}),
|
|
605
|
+
LOG_LEVEL: envEnum(['debug', 'info', 'warn', 'error', 'fatal'] as const, {
|
|
606
|
+
description: 'Log level',
|
|
607
|
+
default: 'info',
|
|
608
|
+
}),
|
|
609
|
+
REDIS_URL: envUrl({
|
|
610
|
+
description: 'Redis connection URL (optional)',
|
|
611
|
+
required: false,
|
|
612
|
+
validator: createUrlParser('any'),
|
|
613
|
+
}),
|
|
614
|
+
DEBUG: envBoolean({ description: 'Enable debug mode', default: false }),
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const registry = createEnvRegistry(schema);
|
|
618
|
+
export const env = registry.validate();
|
|
619
|
+
export type Env = typeof env;
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
// SPFN server entry point
|
|
624
|
+
import { loadEnv } from '@spfn/core/env/loader';
|
|
625
|
+
import { env } from '@/config/env';
|
|
626
|
+
|
|
627
|
+
loadEnv(); // populate process.env from .env* (server reads .env.server too)
|
|
628
|
+
console.log(env.PORT); // validated lazily on access
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
// Next.js — no loadEnv()
|
|
633
|
+
import { env } from '@/config/env';
|
|
634
|
+
const dbUrl = env.DATABASE_URL; // (only resolves if DATABASE_URL is in the Next.js process)
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
---
|
|
638
|
+
|
|
639
|
+
## Types reference
|
|
640
|
+
|
|
641
|
+
```typescript
|
|
642
|
+
type NodeEnv = 'local' | 'development' | 'staging' | 'production' | 'test';
|
|
643
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; // re-exported from logger
|
|
644
|
+
type Parser<T> = (value: string) => T;
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
## Related
|
|
648
|
+
|
|
649
|
+
- [@spfn/core/config](../config/README.md) — application configuration
|
|
650
|
+
- [@spfn/core/logger](../logger/README.md) — logging infrastructure
|
|
651
|
+
- [@spfn/core](../../README.md) — main package documentation
|