@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/index.js +3 -0
- package/package.json +1 -1
- package/src/actions.js +37 -5
- package/src/api.js +16 -1
- package/src/auth.js +18 -3
- package/src/body-limit.js +291 -0
- package/src/check.js +41 -350
- package/src/context.js +66 -16
- package/src/cors.js +245 -0
- package/src/csp.js +218 -0
- package/src/dev.js +215 -10
- package/src/env-schema.js +250 -0
- package/src/headers.js +213 -0
- package/src/json.js +11 -2
- package/src/node-version.js +102 -0
- package/src/page-action.js +225 -0
- package/src/ssr.js +41 -23
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
|
|
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 {
|
|
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
|
|
1107
|
-
|
|
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
|
|
1111
|
-
dev, appDir,
|
|
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
|
-
|
|
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
|
+
}
|