@webjsdev/server 0.8.8 → 0.8.10

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/src/dev.js CHANGED
@@ -4,7 +4,16 @@ import { existsSync } from 'node:fs';
4
4
  import { digestHex } from './crypto-utils.js';
5
5
  import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
6
6
  import { join, extname, resolve, dirname, relative, sep } from 'node:path';
7
- import { createRequire, stripTypeScriptTypes } from 'node:module';
7
+ import { createRequire } from 'node:module';
8
+ // Namespace import, NOT `import { stripTypeScriptTypes }`. On Node < 22.13 the
9
+ // `stripTypeScriptTypes` named export does not exist, and a NAMED import of a
10
+ // missing builtin export is a LINK-TIME SyntaxError that fires before any
11
+ // module body runs, which would defeat the Node-version preflight (issue #238)
12
+ // by crashing the import of @webjsdev/server itself. A namespace import links
13
+ // on every Node (the property is just `undefined` at runtime on old Node), so
14
+ // importing @webjsdev/server succeeds and `assertNodeVersion()` at the top of
15
+ // createRequestHandler throws the clean "you need Node 24+" message instead.
16
+ import * as nodeModule from 'node:module';
8
17
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
18
 
10
19
  // Server-side `.ts` imports are handled natively by Node 24+'s default
@@ -40,6 +49,7 @@ process.emitWarning = function (warning, type, code, ctor) {
40
49
 
41
50
  import { buildRouteTable, matchPage, matchApi } from './router.js';
42
51
  import { ssrPage, ssrNotFound } from './ssr.js';
52
+ import { loadPageAction, runPageAction } from './page-action.js';
43
53
  import { handleApi } from './api.js';
44
54
  import {
45
55
  buildActionIndex,
@@ -56,7 +66,10 @@ import {
56
66
  hashFile,
57
67
  } from './actions.js';
58
68
  import { defaultLogger } from './logger.js';
59
- import { withRequest } from './context.js';
69
+ import { assertNodeVersion } from './node-version.js';
70
+ import { applyEnvValidation } from './env-schema.js';
71
+ import { withRequest, setCspNonce, setBodyLimits } from './context.js';
72
+ import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
60
73
  import { attachWebSocket } from './websocket.js';
61
74
  import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
62
75
  import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
@@ -69,6 +82,8 @@ function kebab(name) {
69
82
  }
70
83
  import { setVendorEntries, setCoreInstall, publishBuildId } from './importmap.js';
71
84
  import { urlFromRequest } from './forwarded.js';
85
+ import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
86
+ import { readBodyLimits, computeServerTimeouts } from './body-limit.js';
72
87
 
73
88
  const MIME = {
74
89
  '.html': 'text/html; charset=utf-8',
@@ -194,6 +209,81 @@ export async function readElideEnabled(appDir) {
194
209
  return true;
195
210
  }
196
211
 
212
+ /**
213
+ * Read the per-path response-header config (`webjs.headers`) from the
214
+ * app's package.json and compile it to URLPattern rules. A missing,
215
+ * malformed, or unreadable config yields an empty rule set (the secure
216
+ * defaults still apply), never a throw.
217
+ *
218
+ * @param {string} appDir
219
+ * @returns {Promise<ReturnType<typeof compileHeaderRules>>}
220
+ */
221
+ export async function readHeaderRules(appDir) {
222
+ try {
223
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
224
+ return compileHeaderRules(pkg);
225
+ } catch {
226
+ return [];
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Read the CSP config (`webjs.csp`) from the app's package.json and
232
+ * normalize it (issue #233). A missing, malformed, or unreadable config
233
+ * yields a disabled config (no nonce minted, no CSP header), never a
234
+ * throw: a broken security knob must fail closed, not take the app down.
235
+ *
236
+ * @param {string} appDir
237
+ * @returns {Promise<ReturnType<typeof readCspConfig>>}
238
+ */
239
+ export async function readCspConfigFromApp(appDir) {
240
+ try {
241
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
242
+ return readCspConfig(pkg);
243
+ } catch {
244
+ return readCspConfig(undefined);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Resolve the request body-size limits (issue #237) from the app's package.json
250
+ * `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env overrides
251
+ * (`WEBJS_MAX_BODY_BYTES` / `WEBJS_MAX_MULTIPART_BYTES`). A missing or
252
+ * unreadable package.json falls through to the secure defaults (env still wins),
253
+ * never a throw.
254
+ *
255
+ * @param {string} appDir
256
+ * @returns {Promise<{ json: number, multipart: number }>}
257
+ */
258
+ export async function readBodyLimitsFromApp(appDir) {
259
+ let pkg;
260
+ try {
261
+ pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
262
+ } catch {
263
+ pkg = undefined;
264
+ }
265
+ return readBodyLimits(pkg);
266
+ }
267
+
268
+ /**
269
+ * Resolve the node:http server timeouts (issue #237) from the app's
270
+ * package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
271
+ * `webjs.keepAliveTimeoutMs` plus the env overrides. A missing or unreadable
272
+ * package.json falls through to the secure defaults (env still wins).
273
+ *
274
+ * @param {string} appDir
275
+ * @returns {Promise<{ requestTimeout: number, headersTimeout: number, keepAliveTimeout: number }>}
276
+ */
277
+ export async function readServerTimeoutsFromApp(appDir) {
278
+ let pkg;
279
+ try {
280
+ pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
281
+ } catch {
282
+ pkg = undefined;
283
+ }
284
+ return computeServerTimeouts(pkg);
285
+ }
286
+
197
287
  /**
198
288
  * Create a reusable, framework-agnostic request handler for a webjs app.
199
289
  * The returned `handle(req)` takes a standard `Request` and resolves to a
@@ -208,6 +298,10 @@ export async function readElideEnabled(appDir) {
208
298
  * }} opts
209
299
  */
210
300
  export async function createRequestHandler(opts) {
301
+ // Preflight: webjs needs Node 24+ (built-in TS strip + recursive fs.watch).
302
+ // Throw a clear Error here so an embedded host (Express/Fastify/Bun/Deno)
303
+ // gets the actionable message at boot, not a cryptic API failure mid-request.
304
+ assertNodeVersion({ onFail: 'throw' });
211
305
  const appDir = resolve(opts.appDir);
212
306
  // Load <appDir>/.env into process.env BEFORE anything else.
213
307
  // buildActionIndex below imports server-only files (lib/*.server.ts,
@@ -217,6 +311,14 @@ export async function createRequestHandler(opts) {
217
311
  // to boot until the user discovered the missing env-load. See
218
312
  // tracker #37.
219
313
  loadAppEnv(appDir);
314
+ // Optional boot-time env validation (#236). If <appDir>/env.{js,ts} exists it
315
+ // default-exports a typed schema or a custom validator function; we run it
316
+ // against process.env (now populated by loadAppEnv) BEFORE buildActionIndex
317
+ // imports any server-only module. A failure throws a clear aggregated Error
318
+ // here, so an embedded host rejects at boot and the CLI exits non-zero,
319
+ // failing fast instead of crashing cryptically mid-request. Absent file is a
320
+ // no-op (opt-in). Coerced + defaulted values are written back to process.env.
321
+ await applyEnvValidation(appDir, { dev: !!opts.dev });
220
322
  const dev = !!opts.dev;
221
323
  const logger = opts.logger || defaultLogger({ dev });
222
324
  const coreDir = locateCoreDir(appDir);
@@ -285,10 +387,32 @@ export async function createRequestHandler(opts) {
285
387
  // Hints, and WebSocket lookups need it available before the first request.
286
388
  const routeTable = await buildRouteTable(appDir);
287
389
 
390
+ // Per-path response-header rules (issue #232), read once from the
391
+ // app's package.json `webjs.headers`. Static config, so no rebuild
392
+ // re-read; the secure defaults need no config and apply regardless.
393
+ const headerRules = await readHeaderRules(appDir);
394
+
395
+ // CSP config (issue #233), read once from the app's package.json
396
+ // `webjs.csp`. OFF by default: when disabled no nonce is minted and no
397
+ // Content-Security-Policy header is set, so an unconfigured app is
398
+ // unchanged. When enabled, `handle()` mints a fresh per-request nonce,
399
+ // makes it the value `cspNonce()` returns (so the SSR'd inline scripts
400
+ // carry it), and sets the matching header carrying the same nonce.
401
+ const cspConfig = await readCspConfigFromApp(appDir);
402
+
403
+ // Request body-size limits (issue #237), read once from the app's
404
+ // package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
405
+ // overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
406
+ // unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
407
+ // (used inside route handlers) enforces the same cap the RPC and page-action
408
+ // paths do.
409
+ const bodyLimits = await readBodyLimitsFromApp(appDir);
410
+
288
411
  const state = {
289
412
  routeTable,
290
413
  actionIndex: null,
291
414
  middleware: null,
415
+ bodyLimits,
292
416
  logger,
293
417
  moduleGraph: null,
294
418
  elidableComponents: new Set(),
@@ -555,6 +679,59 @@ export async function createRequestHandler(opts) {
555
679
  /** @param {Request} req */
556
680
  function handle(req) {
557
681
  return withRequest(req, async () => {
682
+ // CSP (issue #233): when enabled, mint a fresh CSPRNG nonce and store
683
+ // it on the request scope BEFORE producing the response, so the SSR
684
+ // pipeline's `cspNonce()` reads this exact value and stamps it on the
685
+ // inline boot script, the importmap, and the modulepreload hints.
686
+ // Disabled by default, so no nonce is minted and the response is
687
+ // unchanged. One minted value flows mint -> store -> SSR -> header.
688
+ const nonce = cspConfig.enabled ? mintNonce() : '';
689
+ if (nonce) setCspNonce(nonce);
690
+
691
+ // Make the resolved body-size limits (issue #237) readable from the
692
+ // request scope, so `readBody` inside a route handler enforces the same
693
+ // cap the framework's own RPC / page-action body reads do.
694
+ setBodyLimits(state.bodyLimits);
695
+
696
+ const res = await produce(req);
697
+ // Merge in the secure-by-default headers plus the per-path config
698
+ // (issue #232) as the final step, so app middleware, route
699
+ // handlers, and `expose` headers (already on `res`) always win.
700
+ // Applied to every served response (documents, assets, the core
701
+ // runtime, probes), since the defaults are universally safe.
702
+ let pathname = '/';
703
+ try { pathname = new URL(req.url).pathname; } catch { /* keep default */ }
704
+ const merged = applySecurityHeaders(res, {
705
+ pathname,
706
+ https: webRequestIsHttps(req),
707
+ prod: !dev,
708
+ rules: headerRules,
709
+ });
710
+ // Emit the Content-Security-Policy header carrying the SAME minted
711
+ // nonce the SSR'd scripts got (no drift). Set only when CSP is
712
+ // enabled; never clobber a CSP header the app already set (in
713
+ // middleware, a route handler, or via the webjs.headers config), so
714
+ // an explicit app policy still wins.
715
+ if (nonce && !merged.headers.has('content-security-policy') &&
716
+ !merged.headers.has('content-security-policy-report-only')) {
717
+ // readCspConfig already drops a directive whose name/value carries a
718
+ // control char, so buildCspHeader produces a Headers-safe value. The
719
+ // try/catch is a belt-and-suspenders backstop: a surprise value must
720
+ // never throw the response pipeline (fail closed to no CSP header
721
+ // rather than a self-inflicted 500 on every request).
722
+ try {
723
+ merged.headers.set(cspHeaderName(cspConfig), buildCspHeader(cspConfig, nonce));
724
+ } catch {
725
+ /* a malformed policy must not take the request down: serve without CSP */
726
+ }
727
+ }
728
+ return merged;
729
+ });
730
+ }
731
+
732
+ /** @param {Request} req */
733
+ function produce(req) {
734
+ return (async () => {
558
735
  // Health and readiness probes are answered BEFORE ensureReady so a probe
559
736
  // never blocks on the analysis. `/__webjs/health` is liveness (the
560
737
  // process is up and accepting connections). `/__webjs/ready` is 503 until
@@ -623,7 +800,7 @@ export async function createRequestHandler(opts) {
623
800
  }
624
801
  }
625
802
  return next();
626
- });
803
+ })();
627
804
  }
628
805
 
629
806
  /**
@@ -800,6 +977,20 @@ export async function startServer(opts) {
800
977
  }
801
978
  });
802
979
 
980
+ // Inbound server timeouts (issue #237), node:http built-ins. Defends against
981
+ // slowloris and hung connections: `requestTimeout` bounds the time to receive
982
+ // the WHOLE request, `headersTimeout` the time to receive just the headers
983
+ // (node measures both from the same request start, so it is kept strictly
984
+ // under requestTimeout to actually fire), and `keepAliveTimeout` the idle
985
+ // window before a kept-alive socket is closed. Secure production defaults,
986
+ // overridable via `webjs.requestTimeoutMs` / `headersTimeoutMs` /
987
+ // `keepAliveTimeoutMs` in package.json or the matching WEBJS_*_MS env vars.
988
+ // A value of 0 disables that timeout (node's own no-limit sentinel).
989
+ const timeouts = await readServerTimeoutsFromApp(app.appDir);
990
+ server.requestTimeout = timeouts.requestTimeout;
991
+ server.headersTimeout = timeouts.headersTimeout;
992
+ server.keepAliveTimeout = timeouts.keepAliveTimeout;
993
+
803
994
  // WebSocket upgrade handling: any route.js that exports `WS` becomes a
804
995
  // WebSocket endpoint at its URL.
805
996
  attachWebSocket(server, () => app.getRouteTable(), { dev, logger });
@@ -1103,17 +1294,31 @@ async function handleCore(req, ctx) {
1103
1294
  return runWithSegmentMiddleware(req, api.route.middlewares, handler, dev);
1104
1295
  }
1105
1296
 
1106
- // Page route (only for GET/HEAD)
1107
- if (method === 'GET' || method === 'HEAD') {
1297
+ // Page route. GET/HEAD render the page. A NON-GET/HEAD method (POST/PUT/…)
1298
+ // is only routed here when the page module exports an `action` (#244); the
1299
+ // action runs inside the page's segment middleware, then either PRG-redirects
1300
+ // (303) on success, re-renders the same page (422) with field errors on
1301
+ // failure, or honors a thrown redirect()/notFound(). Without an `action`
1302
+ // export, a non-GET/HEAD request falls through to the 404 below, unchanged.
1303
+ {
1108
1304
  const page = matchPage(state.routeTable, path);
1109
1305
  if (page) {
1110
- const handler = () => ssrPage(page.route, page.params, url, {
1111
- dev, appDir, req, moduleGraph: state.moduleGraph,
1306
+ const ssrOpts = {
1307
+ dev, appDir, moduleGraph: state.moduleGraph,
1112
1308
  serverFiles: state.actionIndex.fileToHash,
1113
1309
  elidableComponents: state.elidableComponents,
1114
1310
  inertRouteModules: state.inertRouteModules,
1115
- });
1116
- return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
1311
+ notFoundFile: state.routeTable.notFound,
1312
+ };
1313
+ if (method === 'GET' || method === 'HEAD') {
1314
+ const handler = () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
1315
+ return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
1316
+ }
1317
+ const loaded = await loadPageAction(page.route.file, dev);
1318
+ if (loaded) {
1319
+ const handler = () => runPageAction(page.route, page.params, url, loaded, req, ssrOpts);
1320
+ return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
1321
+ }
1117
1322
  }
1118
1323
  }
1119
1324
 
@@ -1440,7 +1645,7 @@ async function exists(p) {
1440
1645
  * @returns {Promise<string>}
1441
1646
  */
1442
1647
  async function stripTs(source, _abs) {
1443
- return stripTypeScriptTypes(source);
1648
+ return nodeModule.stripTypeScriptTypes(source);
1444
1649
  }
1445
1650
 
1446
1651
  /**
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Startup env-var validation with a typed schema hook (issue #236).
3
+ *
4
+ * webjs auto-loads `<appDir>/.env` into `process.env` at boot, but does no
5
+ * validation, so a missing or misconfigured required var (DATABASE_URL,
6
+ * AUTH_SECRET, ...) fails late and cryptically (a Prisma connect error
7
+ * mid-request, an undefined secret signing a token). This module adds an
8
+ * optional boot-time validation hook that fails fast with one clear message
9
+ * listing EVERY missing or invalid var at once, before the app serves a
10
+ * request.
11
+ *
12
+ * The hook is an optional `env.{js,ts}` module at the app root (sibling of
13
+ * `middleware.js` / `readiness.js`), default-exporting either:
14
+ * 1. a plain SCHEMA object, dependency-free, e.g.
15
+ * export default {
16
+ * DATABASE_URL: 'string',
17
+ * AUTH_SECRET: { type: 'string', required: true, minLength: 16 },
18
+ * PORT: { type: 'number', optional: true, default: 3000 },
19
+ * NODE_ENV: { type: 'enum', values: ['development','production','test'] },
20
+ * };
21
+ * 2. a FUNCTION `(env) => void | throw` for full custom validation (the
22
+ * escape hatch), so an app can use zod or anything it likes without webjs
23
+ * depending on it. A thrown Error is surfaced as the boot failure.
24
+ *
25
+ * The validator is a PURE function (`validateEnv`) so it unit-tests with an
26
+ * injected schema + env object, no temp app dir needed. `loadEnvSchema` reads
27
+ * the app's `env.{js,ts}` (returns `null` when absent, so the feature is fully
28
+ * opt-in), and `applyEnvValidation` is the side-effecting boot wrapper: it runs
29
+ * the schema/function against `process.env`, applies coerced + defaulted values
30
+ * back into `process.env`, and throws a clear aggregated Error on failure (so
31
+ * `createRequestHandler` rejects and the CLI exits non-zero, consistent with
32
+ * the Node-version preflight).
33
+ */
34
+ import { join } from 'node:path';
35
+ import { pathToFileURL } from 'node:url';
36
+ import { stat } from 'node:fs/promises';
37
+
38
+ /** Field type names a schema may declare. */
39
+ const KNOWN_TYPES = new Set(['string', 'number', 'boolean', 'url', 'enum']);
40
+
41
+ /**
42
+ * Normalize a schema field to its object form. A bare string like `'string'`
43
+ * is shorthand for `{ type: 'string' }`.
44
+ * @param {string | object} rule
45
+ * @returns {{ type: string, required?: boolean, optional?: boolean, default?: any, values?: any[], minLength?: number, pattern?: (string|RegExp) }}
46
+ */
47
+ function normalizeRule(rule) {
48
+ if (typeof rule === 'string') return { type: rule };
49
+ return rule && typeof rule === 'object' ? rule : { type: 'string' };
50
+ }
51
+
52
+ /**
53
+ * Is a field required? A field is required by default. It is optional when it
54
+ * declares `optional: true`, `required: false`, or carries a `default`.
55
+ * @param {object} rule normalized rule
56
+ */
57
+ function isRequired(rule) {
58
+ if (rule.required === false) return false;
59
+ if (rule.optional === true) return false;
60
+ if ('default' in rule) return false;
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Coerce a raw string value to the declared type, enforcing the field's
66
+ * constraints. Returns `{ value }` on success or `{ error }` (a human string)
67
+ * on failure.
68
+ * @param {string} name the env var name (for messages)
69
+ * @param {object} rule normalized rule
70
+ * @param {string} raw the raw string from the env
71
+ * @returns {{ value: any } | { error: string }}
72
+ */
73
+ function coerce(name, rule, raw) {
74
+ const type = rule.type || 'string';
75
+ switch (type) {
76
+ case 'string': {
77
+ if (typeof rule.minLength === 'number' && raw.length < rule.minLength) {
78
+ return { error: `${name} must be at least ${rule.minLength} characters (got ${raw.length})` };
79
+ }
80
+ if (rule.pattern != null) {
81
+ const re = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern);
82
+ if (!re.test(raw)) return { error: `${name} does not match required pattern ${re}` };
83
+ }
84
+ return { value: raw };
85
+ }
86
+ case 'number': {
87
+ const n = Number(raw);
88
+ if (raw.trim() === '' || Number.isNaN(n)) {
89
+ // Never echo the value: a secret given the wrong type would leak to logs.
90
+ return { error: `${name} must be a number` };
91
+ }
92
+ return { value: n };
93
+ }
94
+ case 'boolean': {
95
+ const v = raw.trim().toLowerCase();
96
+ if (['1', 'true', 'yes', 'on'].includes(v)) return { value: true };
97
+ if (['0', 'false', 'no', 'off'].includes(v)) return { value: false };
98
+ // Never echo the value: list the accepted spellings, not what was given.
99
+ return { error: `${name} must be a boolean (one of true/false/1/0/yes/no/on/off)` };
100
+ }
101
+ case 'url': {
102
+ try {
103
+ // eslint-disable-next-line no-new
104
+ new URL(raw);
105
+ return { value: raw };
106
+ } catch {
107
+ // Never echo the value: a DSN with embedded credentials must not leak.
108
+ return { error: `${name} must be a valid URL` };
109
+ }
110
+ }
111
+ case 'enum': {
112
+ const values = Array.isArray(rule.values) ? rule.values : [];
113
+ if (!values.includes(raw)) {
114
+ // Name the ALLOWED values (from the schema, safe), never the provided one.
115
+ return { error: `${name} must be one of ${values.map((v) => JSON.stringify(v)).join(', ')}` };
116
+ }
117
+ return { value: raw };
118
+ }
119
+ default:
120
+ return { error: `${name} has an unknown schema type ${JSON.stringify(type)}` };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Pure env validator. Validates `env` against `schema`, collecting ALL errors
126
+ * (never stopping at the first), and computes the coerced + defaulted values to
127
+ * write back. Does NOT mutate `env` or `process.env`; the caller applies
128
+ * `coerced` to `process.env`.
129
+ *
130
+ * When `schema` is a FUNCTION it is the custom-validator escape hatch: it is
131
+ * called with the env object and any thrown Error is surfaced as a single
132
+ * error. A function validator never coerces.
133
+ *
134
+ * @param {object | Function} schema the default export of `env.{js,ts}`
135
+ * @param {Record<string, string|undefined>} env the source env (e.g. process.env)
136
+ * @returns {{ ok: boolean, errors: string[], coerced: Record<string, string> }}
137
+ */
138
+ export function validateEnv(schema, env) {
139
+ // Escape hatch: a function gets the env and validates however it wants.
140
+ if (typeof schema === 'function') {
141
+ try {
142
+ schema(env);
143
+ return { ok: true, errors: [], coerced: {} };
144
+ } catch (e) {
145
+ const msg = e && e.message ? String(e.message) : String(e);
146
+ return { ok: false, errors: [msg], coerced: {} };
147
+ }
148
+ }
149
+
150
+ if (!schema || typeof schema !== 'object') {
151
+ // Nothing to validate against. Treat as a no-op rather than an error so a
152
+ // stray default export does not brick boot.
153
+ return { ok: true, errors: [], coerced: {} };
154
+ }
155
+
156
+ const errors = [];
157
+ /** @type {Record<string, string>} */
158
+ const coerced = {};
159
+
160
+ for (const name of Object.keys(schema)) {
161
+ const rule = normalizeRule(schema[name]);
162
+ const type = rule.type || 'string';
163
+ if (!KNOWN_TYPES.has(type)) {
164
+ errors.push(`${name} declares an unknown type ${JSON.stringify(type)} (expected one of ${[...KNOWN_TYPES].join(', ')})`);
165
+ continue;
166
+ }
167
+ const present = env[name] != null && env[name] !== '';
168
+ if (!present) {
169
+ if (isRequired(rule)) {
170
+ errors.push(`${name} is required but missing`);
171
+ } else if ('default' in rule) {
172
+ // Apply the default, coercing it through the same path so a number
173
+ // default lands as a string in process.env (env values are strings).
174
+ coerced[name] = String(rule.default);
175
+ }
176
+ continue;
177
+ }
178
+ const result = coerce(name, rule, String(env[name]));
179
+ if ('error' in result) {
180
+ errors.push(result.error);
181
+ } else if (typeof result.value !== 'string') {
182
+ // Re-stringify coerced non-string values so process.env stays string-typed.
183
+ coerced[name] = String(result.value);
184
+ }
185
+ }
186
+
187
+ return { ok: errors.length === 0, errors, coerced };
188
+ }
189
+
190
+ /**
191
+ * Compose the aggregated, actionable failure message from a list of errors.
192
+ * @param {string[]} errors
193
+ * @returns {string}
194
+ */
195
+ export function formatEnvErrors(errors) {
196
+ const lines = errors.map((e) => ` - ${e}`);
197
+ return (
198
+ `webjs env validation failed (${errors.length} ${errors.length === 1 ? 'error' : 'errors'}):\n` +
199
+ lines.join('\n') +
200
+ `\n\nFix the variables above in your .env (or the host environment) and restart. ` +
201
+ `The schema lives in env.{js,ts} at the app root.`
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Load the optional `env.{js,ts}` schema module from the app root. Returns the
207
+ * default export (a schema object or a validator function), or `null` when no
208
+ * such file exists, so env validation is fully opt-in.
209
+ * @param {string} appDir
210
+ * @param {{ dev?: boolean }} [opts]
211
+ * @returns {Promise<object | Function | null>}
212
+ */
213
+ export async function loadEnvSchema(appDir, opts = {}) {
214
+ let file = null;
215
+ for (const name of ['env.ts', 'env.js', 'env.mts', 'env.mjs']) {
216
+ const p = join(appDir, name);
217
+ try {
218
+ await stat(p);
219
+ file = p;
220
+ break;
221
+ } catch {
222
+ // not this name, try the next
223
+ }
224
+ }
225
+ if (!file) return null;
226
+ const url = pathToFileURL(file).toString();
227
+ const bust = opts.dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
228
+ const mod = await import(url + bust);
229
+ return mod.default ?? null;
230
+ }
231
+
232
+ /**
233
+ * Side-effecting boot wrapper: load the app's `env.{js,ts}` (if any), validate
234
+ * `process.env` against it, apply coerced + defaulted values back into
235
+ * `process.env`, and THROW a clear aggregated Error on failure. A no-op when
236
+ * `env.{js,ts}` is absent. Called by `createRequestHandler` right after the
237
+ * `.env` auto-load and before any server-only module is imported.
238
+ * @param {string} appDir
239
+ * @param {{ dev?: boolean, env?: Record<string, string|undefined> }} [opts]
240
+ * @returns {Promise<void>}
241
+ */
242
+ export async function applyEnvValidation(appDir, opts = {}) {
243
+ const schema = await loadEnvSchema(appDir, opts);
244
+ if (schema == null) return; // opt-in: no env.{js,ts}, nothing to do
245
+ const env = opts.env ?? process.env;
246
+ const { ok, errors, coerced } = validateEnv(schema, env);
247
+ if (!ok) throw new Error(formatEnvErrors(errors));
248
+ // Apply coerced values + defaults back so the app reads the coerced value.
249
+ for (const key of Object.keys(coerced)) env[key] = coerced[key];
250
+ }