@spfn/core 0.3.0-beta.15 → 0.3.0-beta.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.15",
3
+ "version": "0.3.0-beta.17",
4
4
  "description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
5
5
  "type": "module",
6
6
  "exports": {
@@ -209,7 +209,7 @@
209
209
  "pg-boss": "^11.1.2",
210
210
  "postgres": "^3.4.0",
211
211
  "typescript": "^5.3.3",
212
- "undici": "^7.29.0",
212
+ "undici": "^7.29.1",
213
213
  "zod": "^4.4.3"
214
214
  },
215
215
  "optionalDependencies": {
@@ -81,13 +81,18 @@ env.SPFN_API_URL; // read + validated HERE; throws if missing (required) or inv
81
81
 
82
82
  Consequences:
83
83
 
84
- - A missing **required** variable (`SPFN_API_URL`, `NEXT_PUBLIC_SPFN_API_URL`) does not
84
+ - A missing **required** variable (`SPFN_API_URL`) does not
85
85
  throw on import — it throws on first access (`Error('Environment validation failed')`).
86
86
  - You may import `env` before `loadEnv()` runs; values are picked up lazily.
87
87
  - For an **eager** all-at-once check (CI / startup gate), call `registry.validateAll()`
88
88
  (returns `{ errors, warnings }`) or pass `registry` to `validateAllEnv([...])` from
89
89
  `@spfn/core/env`. `SKIP_ENV_VALIDATION=true` skips only the lazy `required` check, not
90
90
  these eager checks.
91
+ - **The SPFN server runs that eager check at boot.** `startServer` validates this
92
+ registry — plus any registries the app hands over with
93
+ `defineServerConfig().env({ registries })` — before it listens, and exits with code 1
94
+ listing each failing key (never a value). See
95
+ [Boot-time check](../env/README.md#boot-time-check-the-server).
91
96
 
92
97
  See [@spfn/core/env](../env/README.md) for the full registry / proxy / `SKIP_ENV_VALIDATION`
93
98
  semantics — this module inherits all of it unchanged.
@@ -221,24 +226,25 @@ validator **throw on access** if the value is present but malformed.
221
226
  | Variable | Type | Required | Validator | Notes |
222
227
  |----------|------|----------|-----------|-------|
223
228
  | `SPFN_API_URL` | `string` (`envUrl`) | **Yes** | URL | `nextjs: true`; Next.js → backend |
224
- | `NEXT_PUBLIC_SPFN_API_URL` | `string` (`envUrl`) | **Yes** | URL | `nextjs: true`; client-exposed |
225
- | `SPFN_APP_URL` | `string` (`envUrl`) | No | URL | `nextjs: true`; SPFN server → Next.js |
229
+ | `NEXT_PUBLIC_SPFN_API_URL` | `string` (`envUrl`) | No | URL | `nextjs: true`; client-exposed, inlined by Next.js at build; the server uses `SPFN_API_URL` |
230
+ | `SPFN_APP_URL` | `string` (`envUrl`) | No (no default) | URL | `nextjs: true`; SPFN server → Next.js |
226
231
  | `RPC_PROXY_TIMEOUT` | `number` | No (default `120000`) | — | `nextjs: true`; keep < `FETCH_HEADERS_TIMEOUT` |
227
232
 
228
- > `SPFN_API_URL` and `NEXT_PUBLIC_SPFN_API_URL` are both `envUrl` and both **required** —
229
- > a missing or non-URL value throws on first access of that property.
233
+ > `SPFN_API_URL` is **required** — a missing or non-URL value throws on first access.
234
+ > `NEXT_PUBLIC_SPFN_API_URL` is optional to the server, which falls back to `SPFN_API_URL`;
235
+ > set it wherever browser code needs the API URL. These three URLs are declared by
236
+ > `@spfn/core` only — packages read them from `@spfn/core/config` rather than declaring them again.
230
237
 
231
238
  ---
232
239
 
233
240
  ## Pitfalls & anti-patterns
234
241
 
235
242
  - **Don't expect validation on import.** Importing `@spfn/core/config` validates nothing;
236
- the proxy validates each variable on property access. A missing `SPFN_API_URL` /
237
- `NEXT_PUBLIC_SPFN_API_URL` throws when you *read* it, not when you import `env`. For a
238
- startup gate, call `registry.validateAll()` explicitly.
239
- - **`SPFN_API_URL` and `NEXT_PUBLIC_SPFN_API_URL` are both required and both URLs.** Setting
240
- only one of them will throw on access of the other. They are validated as URLs (`envUrl`),
241
- so a non-URL string fails too.
243
+ the proxy validates each variable on property access. A missing `SPFN_API_URL` throws
244
+ when you *read* it, not when you import `env`. The SPFN server does check it eagerly at
245
+ boot (`startServer` / `createServerlessApp`), so a server process needs it set.
246
+ - **The three URLs are validated as URLs (`envUrl`).** A non-URL string in `SPFN_API_URL`,
247
+ `NEXT_PUBLIC_SPFN_API_URL` or `SPFN_APP_URL` fails on access and at boot.
242
248
  - **Don't call `loadEnv()` inside Next.js.** Next.js already loads `.env*`. Calling it there
243
249
  (with `server: true`) would pull `.env.server` secrets into the Next.js process. In the
244
250
  **SPFN server** entry point you *do* call `loadEnv()` before touching `env`.
@@ -265,7 +271,8 @@ import { env, registry } from '@spfn/core/config';
265
271
 
266
272
  loadEnv(); // populate process.env from .env* (server includes .env.server)
267
273
 
268
- // Optional eager gate: fail fast on missing/invalid required vars at startup.
274
+ // Eager gate for a custom entry point — startServer() already runs this check
275
+ // (see "Boot-time check" in the env README), so an SPFN server needs none of it.
269
276
  const { errors, warnings } = registry.validateAll();
270
277
  for (const w of warnings) console.warn(`${w.key}: ${w.message}`);
271
278
  if (errors.length)
@@ -296,7 +303,7 @@ const apiUrl = env.SPFN_API_URL; // required URL — throws if unset/in
296
303
  // Next.js — no loadEnv()
297
304
  import { env } from '@spfn/core/config';
298
305
 
299
- const apiUrl = env.NEXT_PUBLIC_SPFN_API_URL; // required URL, client-exposed
306
+ const apiUrl = env.NEXT_PUBLIC_SPFN_API_URL; // optional URL, client-exposed
300
307
  ```
301
308
 
302
309
  ```typescript
package/src/env/README.md CHANGED
@@ -160,6 +160,8 @@ CONFIG: envJson<{ host: string; port: number }>({
160
160
  | `sensitive` | `boolean` | Marks a secret; triggers a warning if `NEXT_PUBLIC_*` |
161
161
  | `examples` | `T[]` | Example values (metadata only) |
162
162
  | `nextjs` | `boolean` | File-separation hint (see below). Defaults to `true` for `NEXT_PUBLIC_*`, else `false` |
163
+ | `layer` | `'environment' \| 'instance'` | Which deployment layer must supply the value (see below). Unset: may come from any layer |
164
+ | `readBy` | `string[]` | Files that read the name under a computed key, relative to the project root — for `spfn env audit` (see below) |
163
165
 
164
166
  > There is **no** `category` option. Docs that show `category: '...'` are stale — passing
165
167
  > it is harmless (excess property) but it does nothing.
@@ -249,6 +251,137 @@ for (const w of result.warnings) {
249
251
  A single registry exposes the same via `registry.validateAll()` returning just
250
252
  `{ errors, warnings }`.
251
253
 
254
+ Messages carry the key and the rule, never the value. For a `sensitive` key the validator's
255
+ own message is replaced by `(value hidden — sensitive key)`, because a validator may echo its
256
+ input; core's parsers describe the expected shape instead of quoting the input. An app
257
+ validator on a non-sensitive key should do the same.
258
+
259
+ ### Boot-time check (the server)
260
+
261
+ `startServer()` runs `validateAllEnv` right after loading the env files and the app's
262
+ `server.config`, before it initializes infrastructure or listens. The list is `@spfn/core`'s
263
+ registry plus the registries the app hands over in `server.config`:
264
+
265
+ ```typescript
266
+ // src/server/config/env.config.ts
267
+ export const envSchema = defineEnvSchema({ /* ... */ });
268
+ export const envRegistry = createEnvRegistry(envSchema);
269
+ export const env = envRegistry.validate();
270
+
271
+ // src/server/server.config.ts
272
+ import { envRegistry } from '@/server/config/env.config';
273
+
274
+ export default defineServerConfig()
275
+ .env({ registries: [envRegistry] })
276
+ .build();
277
+ ```
278
+
279
+ On failure it logs each key with its reason (never a value) and exits with code 1. A
280
+ package's variables are checked only when the app passes a registry for them —
281
+ `createEnvRegistry(envSchema)` over the schema the package exports from `./config`.
282
+
283
+ - `SKIP_ENV_VALIDATION` does not apply: it relaxes the lazy proxy for build steps, and a
284
+ server about to serve is not a build step.
285
+ - `createServerlessApp()` runs the same check and rejects its promise instead of exiting.
286
+ - `createServer()` (the app factory both of them use) and `provisionInfrastructure()` do not
287
+ run it.
288
+ - `@spfn/core` itself requires `SPFN_API_URL`, so the server process needs it set.
289
+ `NEXT_PUBLIC_SPFN_API_URL` is optional there: Next.js inlines it at build time and the
290
+ server falls back to `SPFN_API_URL`.
291
+
292
+ ### The app's schema in the CLI (`spfn.config.js` `env.schemas`)
293
+
294
+ `spfn env` and `spfn secret` find a package's schema at `<package>/config`; an app's own
295
+ schema can live anywhere, so `spfn.config.js` names it:
296
+
297
+ ```js
298
+ // spfn.config.js
299
+ export default {
300
+ env: {
301
+ schemas: ['src/server/config/env.config.ts'], // modules exporting `envSchema`
302
+ },
303
+ };
304
+ ```
305
+
306
+ Paths are relative to the project root; TypeScript and tsconfig path aliases are supported.
307
+ Without `-p <package>`, the CLI reads these modules plus every installed `@spfn/*` package
308
+ whose `./config` exports `envSchema`, and groups its output by source. A key two sources
309
+ declare identically is listed once; two declarations that differ in type, `required` or
310
+ `sensitive` are an error naming the key and both sources — one variable has one owner, and
311
+ other packages read it from the owner's `env` instead of declaring it again.
312
+
313
+ ### Auditing the code against the list (`env.audit`, `readBy`)
314
+
315
+ `spfn env audit` keeps the list and the code in step, and exits 1 on any finding. It prints
316
+ `file:line NAME reason` — names only, never values — for:
317
+
318
+ - **Direct reads.** `process.env.X`, `process.env['X']`, `process.env[expr]` and
319
+ `const { X } = process.env` (also through `process?.env` and `globalThis.process.env`).
320
+ Allowed in the modules named in `env.schemas`, in root-level `*.config.{js,cjs,mjs,ts}`
321
+ files and in files matching `env.audit.ignore`. `NODE_ENV` is allowed everywhere.
322
+ - **Unused declarations.** A name in the app's own schemas that no scanned file outside the
323
+ schema modules reads — as a property (`env.X`), a destructured key (`const { X } = env`)
324
+ or a string literal `'X'`. Names an installed `@spfn/*` package also declares are read
325
+ inside that package and are not checked. Without `env.schemas` this check is skipped
326
+ with a notice.
327
+
328
+ ```js
329
+ // spfn.config.js
330
+ export default {
331
+ env: {
332
+ schemas: ['src/server/config/env.config.ts'],
333
+ audit: {
334
+ include: ['src'], // default; directories or globs from the project root
335
+ ignore: ['src/**/*.test.ts'], // may read process.env; the names they read do not count
336
+ },
337
+ },
338
+ };
339
+ ```
340
+
341
+ `node_modules`, build output (`dist`, `build`, `out`, `.next`, …), `*.d.ts` files and
342
+ symbolic links are never scanned. Sources are parsed with TypeScript, so a mention in a
343
+ comment or a string is not a read.
344
+
345
+ A name the code only reaches under a computed key — ``env[`${provider}_API_KEY`]`` — is
346
+ invisible to the scan. List the files that read it in `readBy`:
347
+
348
+ ```typescript
349
+ const schema = defineEnvSchema({
350
+ PAYMENT_API_KEY: envSecret({ description: 'Payment provider key', readBy: ['src/server/providers.ts'] }),
351
+ });
352
+ ```
353
+
354
+ The name passes when every listed file exists and is scanned; a listed file that does not
355
+ exist, or that the audit does not scan, is a finding. The runtime ignores `readBy`.
356
+
357
+ A name that appears only in a log message string counts as read — the string-literal rule
358
+ cannot tell a message from a lookup key.
359
+
360
+ ### Deployment layers (`layer`)
361
+
362
+ A deployed environment can run more than one instance — `staging` as `blue` and `green`,
363
+ each with its own port, URLs and database. `layer` pins where a variable's value must come
364
+ from, so `spfn secret export` can assemble one instance's env file and refuse a value that
365
+ arrives from the wrong place. Unset, the value may come from any layer — a package schema
366
+ leaves it unset, so an app can keep a value such as a session secret shared or give each
367
+ instance its own:
368
+
369
+ | Layer | Holds | Source |
370
+ |-------|-------|--------|
371
+ | `environment` | Values every instance of the environment shares — third-party API keys | `secrets/<env>.enc.json` |
372
+ | `instance` | Values of one instance: its own secrets, or plain values the deploy script computes (port, URLs, database name) | `secrets/<env>.<instance>.enc.json`, or a `--with <file>` dotenv file |
373
+
374
+ ```typescript
375
+ const schema = defineEnvSchema({
376
+ PAYMENT_API_KEY: envSecret({ description: 'Payment provider key', required: true, layer: 'environment' }),
377
+ DB_NAME: envString({ description: 'Database of this instance', required: true, layer: 'instance' }),
378
+ });
379
+ ```
380
+
381
+ A name declared `environment` found in an instance layer, a name declared `instance` found
382
+ in the environment file, or any name found in two layers fails the export. The runtime ignores
383
+ `layer`; it only steers the CLI.
384
+
252
385
  ### Fallback keys
253
386
 
254
387
  ```typescript
@@ -556,7 +689,10 @@ optional(parseRedisUrl); // '' → undefined,
556
689
  - **`sensitive: true` + `NEXT_PUBLIC_` is wrong.** It only emits a warning, not an error —
557
690
  the secret is still exposed. Remove the prefix.
558
691
  - **`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.
692
+ present values are still validated, and `validateAll()` — and with it the server's boot
693
+ check — ignores the flag entirely.
694
+ - **A validator message must not contain the value.** Validation errors are logged. Throw
695
+ `'Must be a valid number'`, not `` `got: ${value}` ``.
560
696
  - **Removed API.** `getEnvVar`, `requireEnvVar`, `hasEnvVar`, `getEnvVars`,
561
697
  `loadEnvironment`, `namespace`/`useFolderStructure`/`customPaths`/`useCache` loader
562
698
  options, `env.get()`/`env.require()`, `getByCategory()`/`getAllSchemas()`,