@webjsdev/server 0.8.63 → 0.8.65
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.d.ts +7 -2
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +173 -26
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- package/src/vendor.js +29 -2371
package/src/component-elision.js
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
redactToPlaceholders,
|
|
46
46
|
} from './js-scan.js';
|
|
47
47
|
import { transitiveDeps, expandImportAlias } from './module-graph.js';
|
|
48
|
+
import { ensureStripper } from './ts-strip.js';
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Named imports from a `@webjsdev/core` specifier that imply the
|
|
@@ -244,7 +245,7 @@ const CLIENT_ROUTER_SUBPATH_RE = /['"]@webjsdev\/core\/client-router['"]/;
|
|
|
244
245
|
* REACTIVE_IMPORTS: a router function the barrel re-exports but this list omits
|
|
245
246
|
* is not recognized as client work, so a component using it is wrongly elided.
|
|
246
247
|
*/
|
|
247
|
-
export const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate', 'loadFrame'];
|
|
248
|
+
export const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate', 'refreshPage', 'loadFrame'];
|
|
248
249
|
|
|
249
250
|
/** Identifiers that only exist in a browser; their presence means client work. */
|
|
250
251
|
const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
|
|
@@ -267,6 +268,23 @@ const INSTANCEOF_RE = /\binstanceof\s+([A-Z][A-Za-z0-9_$]*)/g;
|
|
|
267
268
|
* `customElements.define(...)` legitimately uses it and must not force ship). */
|
|
268
269
|
const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;
|
|
269
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Constructors that produce inert DATA with no side effect, so a module-scope
|
|
273
|
+
* `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
|
|
274
|
+
* parsed URL) is not client work and must not pin an importing page/layout
|
|
275
|
+
* (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
|
|
276
|
+
* `new EventSource()`, `new Audio()`) IS a side effect and still ships.
|
|
277
|
+
*/
|
|
278
|
+
const PURE_DATA_CONSTRUCTORS = new Set([
|
|
279
|
+
'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
|
|
280
|
+
'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
|
|
281
|
+
'Error', 'TypeError', 'RangeError', 'SyntaxError',
|
|
282
|
+
'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
|
|
283
|
+
'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
|
|
284
|
+
'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
|
|
285
|
+
'BigInt64Array', 'BigUint64Array',
|
|
286
|
+
]);
|
|
287
|
+
|
|
270
288
|
/**
|
|
271
289
|
* Module-scope client work, detected by an ALLOWLIST of safe top-level forms
|
|
272
290
|
* rather than a denylist of browser globals. A module that runs ANY code when
|
|
@@ -285,13 +303,22 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|
|
|
|
285
303
|
*
|
|
286
304
|
* Scans the redacted copy (strings / templates / comments blanked, regex
|
|
287
305
|
* literals and nested `${...}` interpolation tracked by the lexer) so template
|
|
288
|
-
* prose and JSDoc
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
306
|
+
* prose and JSDoc cannot trip it; quoted-string bodies, which redaction keeps
|
|
307
|
+
* verbatim for other rules, are blanked here too so a string like `"foo()"` or
|
|
308
|
+
* `"{"` is not read as a call and cannot unbalance the brace scan. The
|
|
309
|
+
* unbalanced-brace and unterminated-string fallbacks below are defense in
|
|
310
|
+
* depth: with the lexer tracking regex literals, neither should trigger on
|
|
311
|
+
* valid code, but if either does the module ships rather than risk hiding
|
|
312
|
+
* client work.
|
|
313
|
+
*
|
|
314
|
+
* PASS TYPE-ERASED SOURCE for a TypeScript module. Redaction blanks comments
|
|
315
|
+
* and literals, NOT type syntax, and TS annotations are call-shaped often
|
|
316
|
+
* enough to matter: `readonly (readonly [number, number, number])[]` is an
|
|
317
|
+
* identifier immediately followed by `(`, so a module holding nothing but a
|
|
318
|
+
* typed data literal reads as running code at module scope (#1423). The
|
|
319
|
+
* analyser erases types before it calls this (`eraseTypesForScan`), so the
|
|
320
|
+
* request path is covered; a DIRECT caller handing it `.ts` source as authored
|
|
321
|
+
* is opting into that false positive.
|
|
295
322
|
*
|
|
296
323
|
* Over-detection is safe (a top-level arrow whose body calls something, or a
|
|
297
324
|
* pure top-level helper call, only ships). The accepted residual misses, all
|
|
@@ -299,25 +326,10 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|
|
|
|
299
326
|
* top-level object / array initializer or a destructuring default, and a
|
|
300
327
|
* side-effecting tagged-template hole evaluated at module scope.
|
|
301
328
|
*
|
|
302
|
-
* @param {string} src
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
* Constructors that produce inert DATA with no side effect, so a module-scope
|
|
306
|
-
* `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
|
|
307
|
-
* parsed URL) is not client work and must not pin an importing page/layout
|
|
308
|
-
* (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
|
|
309
|
-
* `new EventSource()`, `new Audio()`) IS a side effect and still ships.
|
|
329
|
+
* @param {string} src module source, type-erased if it came from a TypeScript file
|
|
330
|
+
* @param {string[]} [literals] the redaction's literal bodies, when already computed
|
|
331
|
+
* @returns {boolean}
|
|
310
332
|
*/
|
|
311
|
-
const PURE_DATA_CONSTRUCTORS = new Set([
|
|
312
|
-
'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
|
|
313
|
-
'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
|
|
314
|
-
'Error', 'TypeError', 'RangeError', 'SyntaxError',
|
|
315
|
-
'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
|
|
316
|
-
'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
|
|
317
|
-
'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
|
|
318
|
-
'BigInt64Array', 'BigUint64Array',
|
|
319
|
-
]);
|
|
320
|
-
|
|
321
333
|
export function hasModuleScopeSideEffect(src, literals) {
|
|
322
334
|
let redacted = src;
|
|
323
335
|
if (!literals) {
|
|
@@ -871,6 +883,131 @@ export function extractRenderedTags(src) {
|
|
|
871
883
|
return tags;
|
|
872
884
|
}
|
|
873
885
|
|
|
886
|
+
/**
|
|
887
|
+
* The extensions that can carry TypeScript type syntax INTO this analysis.
|
|
888
|
+
*
|
|
889
|
+
* Derive it from what actually reaches `allFiles`, which is seeded from three
|
|
890
|
+
* places with three different filters: the component set from `scanComponents`
|
|
891
|
+
* (`/\.m?[jt]sx?$/`), the route modules from the router (`js|mjs|ts|mts`), and
|
|
892
|
+
* the module graph from its walker (`/\.(js|ts|mjs|mts)$/`). The COMPONENT set
|
|
893
|
+
* is the widest and it does not pass through the other two, so a `.tsx`
|
|
894
|
+
* component arrives here whatever the walker and the router admit. Hence
|
|
895
|
+
* `.tsx`, even though nothing in WebJs serves one: leaving it out is how the
|
|
896
|
+
* #1423 false positive comes back for exactly the file class no other filter
|
|
897
|
+
* would have let through. `.cts` is absent because no filter admits it.
|
|
898
|
+
*
|
|
899
|
+
* The rule to hold, if any of those three filters moves: this set is every
|
|
900
|
+
* TypeScript-carrying extension in their UNION. Narrowing it to the servable
|
|
901
|
+
* set instead is a change that looks tighter and silently un-erases files.
|
|
902
|
+
*/
|
|
903
|
+
const TS_SOURCE_RE = /\.m?tsx?$/;
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Per-file memo of {@link eraseTypesForScan}, holding the source it was derived
|
|
907
|
+
* from so a hit is proven rather than assumed. One entry per file, so it is
|
|
908
|
+
* bounded by the app's file count and needs no eviction policy; a deleted file
|
|
909
|
+
* leaves one dead entry until the process ends, which is cheaper than tracking
|
|
910
|
+
* liveness for it.
|
|
911
|
+
*
|
|
912
|
+
* @type {Map<string, { src: string, out: string }>}
|
|
913
|
+
*/
|
|
914
|
+
const STRIP_CACHE = new Map();
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Return `src` with its TYPE syntax erased, so every scan below reads the code
|
|
918
|
+
* that actually RUNS rather than the code as authored (#1423).
|
|
919
|
+
*
|
|
920
|
+
* The scans are lexical heuristics, and TypeScript annotations are syntax the
|
|
921
|
+
* heuristics were never designed for: `readonly (readonly [number, number,
|
|
922
|
+
* number])[]` is an identifier immediately followed by `(`, which the
|
|
923
|
+
* top-level-call matcher in `hasModuleScopeSideEffect` reads as a call, so a
|
|
924
|
+
* module holding nothing but a typed data literal was classified as running
|
|
925
|
+
* code at module scope and pinned every route module that imported it. Type
|
|
926
|
+
* syntax is erased before anything runs, so it can never be a runtime signal,
|
|
927
|
+
* and the only sound reading of it is none at all.
|
|
928
|
+
*
|
|
929
|
+
* This uses the framework's own stripper rather than a lexical annotation
|
|
930
|
+
* matcher: it is the same erasure the browser is served, so the analyser and
|
|
931
|
+
* the runtime agree by construction, where a hand-rolled matcher would meet
|
|
932
|
+
* generics, `as`, `satisfies`, and conditional types and get some of them
|
|
933
|
+
* wrong. It is position-preserving whitespace replacement, so every offset,
|
|
934
|
+
* line, and column the other scans depend on is unchanged.
|
|
935
|
+
*
|
|
936
|
+
* A non-TypeScript file is returned untouched (there is nothing to erase, and
|
|
937
|
+
* running a TS parser over one buys only new ways to fail). A PER-FILE strip
|
|
938
|
+
* failure also returns the source untouched, which is the pre-#1423 behaviour
|
|
939
|
+
* and therefore the conservative direction: the scans then over-detect at
|
|
940
|
+
* worst. Two things take that path, both silent because both are one file's
|
|
941
|
+
* problem. A non-erasable module, which invariant 10 forbids and `webjs check`
|
|
942
|
+
* catches at edit time, so it is a broken app rather than a supported one. And
|
|
943
|
+
* a `.tsx` holding real JSX, which the stripper parses as non-JSX TypeScript
|
|
944
|
+
* and rejects, so a JSX component is scanned as authored while a `.tsx` that is
|
|
945
|
+
* only TypeScript is erased normally.
|
|
946
|
+
*
|
|
947
|
+
* A missing stripper BACKEND is the failure that does not stay silent, and it
|
|
948
|
+
* is why the backend is resolved once by the caller rather than per file. It
|
|
949
|
+
* means no TypeScript in the app can be erased (a runtime with neither the
|
|
950
|
+
* built-in nor `amaro`), so every module is scanned as authored and the #1423
|
|
951
|
+
* verdict comes back app-wide. Swallowing that alongside a syntax rejection
|
|
952
|
+
* would make an app-wide regression indistinguishable from one broken file, so
|
|
953
|
+
* `resolveScanStripper` warns once and this returns the source untouched.
|
|
954
|
+
*
|
|
955
|
+
* Results are memoized per file and validated against the exact source, since
|
|
956
|
+
* this runs on every dev rebuild and stripping costs roughly 50x the read it
|
|
957
|
+
* sits beside. Validating on content rather than mtime means a same-mtime
|
|
958
|
+
* rewrite cannot serve a stale strip.
|
|
959
|
+
*
|
|
960
|
+
* @param {import('./ts-strip.js').Stripper | null} stripper resolved once per run, null when unavailable
|
|
961
|
+
* @param {string} file absolute path, the cache key and the extension source
|
|
962
|
+
* @param {string} src
|
|
963
|
+
* @returns {string}
|
|
964
|
+
*/
|
|
965
|
+
function eraseTypesForScan(stripper, file, src) {
|
|
966
|
+
if (!stripper || !TS_SOURCE_RE.test(file)) return src;
|
|
967
|
+
const hit = STRIP_CACHE.get(file);
|
|
968
|
+
if (hit && hit.src === src) return hit.out;
|
|
969
|
+
let out;
|
|
970
|
+
try {
|
|
971
|
+
out = stripper.fn(src);
|
|
972
|
+
} catch {
|
|
973
|
+
out = src;
|
|
974
|
+
}
|
|
975
|
+
STRIP_CACHE.set(file, { src, out });
|
|
976
|
+
return out;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Whether {@link resolveScanStripper} has already reported a missing backend. */
|
|
980
|
+
let warnedNoStripper = false;
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Resolve the TypeScript stripper backend once for a whole analysis run, or
|
|
984
|
+
* `null` when this runtime has none.
|
|
985
|
+
*
|
|
986
|
+
* `analyzeElision` runs outside the dev request handler too (`webjs check`,
|
|
987
|
+
* `webjs elision`), and `ensureStripper` is called on the dev path only, so
|
|
988
|
+
* this is where the no-backend case surfaces for the other entry points. It
|
|
989
|
+
* warns once per process rather than per file: the condition is a property of
|
|
990
|
+
* the runtime, so repeating it once per module would bury it.
|
|
991
|
+
*
|
|
992
|
+
* @returns {Promise<import('./ts-strip.js').Stripper | null>}
|
|
993
|
+
*/
|
|
994
|
+
async function resolveScanStripper() {
|
|
995
|
+
try {
|
|
996
|
+
return await ensureStripper();
|
|
997
|
+
} catch (e) {
|
|
998
|
+
if (!warnedNoStripper) {
|
|
999
|
+
warnedNoStripper = true;
|
|
1000
|
+
console.warn(
|
|
1001
|
+
'[webjs] elision analysis could not resolve a TypeScript stripper, so type syntax ' +
|
|
1002
|
+
'is not erased before the scans read a module. A type annotation can then read as ' +
|
|
1003
|
+
'module-scope work, which ships modules that would otherwise be elided. Underlying: ' +
|
|
1004
|
+
(e && e.message ? e.message : String(e)),
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
874
1011
|
/**
|
|
875
1012
|
* Compute the set of component FILES whose browser download can be
|
|
876
1013
|
* elided. A file is elidable only when every component it defines is
|
|
@@ -978,6 +1115,11 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
978
1115
|
for (const v of vs) if (!appDir || v.startsWith(appDir)) allFiles.add(v);
|
|
979
1116
|
}
|
|
980
1117
|
|
|
1118
|
+
// Resolve the stripper ONCE for the run: the backend is a property of the
|
|
1119
|
+
// runtime, not of a file, and the no-backend case must be reported rather
|
|
1120
|
+
// than swallowed per module (see `eraseTypesForScan`).
|
|
1121
|
+
const scanStripper = await resolveScanStripper();
|
|
1122
|
+
|
|
981
1123
|
for (const file of allFiles) {
|
|
982
1124
|
if (SERVER_FILE_RE.test(file)) { serverFiles.add(file); continue; }
|
|
983
1125
|
let src;
|
|
@@ -989,6 +1131,11 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
989
1131
|
continue;
|
|
990
1132
|
}
|
|
991
1133
|
if (typeof src !== 'string') continue;
|
|
1134
|
+
// Erase type syntax first, so every scan below reads the code that RUNS
|
|
1135
|
+
// (#1423). This is the single point where the analyser reads a file, so
|
|
1136
|
+
// doing it here covers the module-scope, template, import, and component
|
|
1137
|
+
// scans at once.
|
|
1138
|
+
src = eraseTypesForScan(scanStripper, file, src);
|
|
992
1139
|
// Mask comments once for every signal scan below (#179): a `<tag>`, an
|
|
993
1140
|
// `@event`, a browser global, an `import`, or a `whenDefined` written in a
|
|
994
1141
|
// comment must not register as a real signal. String and template content
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
import { compileHeaderRules } from '../headers.js';
|
|
4
|
+
import { compileRedirectRules, readTrailingSlashPolicy } from '../redirects.js';
|
|
5
|
+
import { readBasePath } from '../base-path.js';
|
|
6
|
+
import { validateAppWebjsConfig } from '../webjs-config-validate.js';
|
|
7
|
+
import { readAllowedOrigins } from '../csrf.js';
|
|
8
|
+
import { readCspConfig } from '../csp.js';
|
|
9
|
+
import { readBodyLimits, computeServerTimeouts } from '../body-limit.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read the `WEBJS_ELIDE` environment override, if set.
|
|
13
|
+
* `0` / `false` / `off` / `no` (case-insensitive) force elision OFF;
|
|
14
|
+
* `1` / `true` / `on` / `yes` force it ON. Any other value, or an unset
|
|
15
|
+
* variable, returns `undefined` so the caller falls through to the
|
|
16
|
+
* `package.json` switch. The env override is the deploy-time / ops escape
|
|
17
|
+
* hatch: force-disable elision to rule it out while debugging a wrong-strip
|
|
18
|
+
* without editing committed code, or force-enable it regardless of an
|
|
19
|
+
* app's `package.json`. It is also the seam the differential elision test
|
|
20
|
+
* uses to render the same app on and off in one process.
|
|
21
|
+
* @returns {boolean | undefined}
|
|
22
|
+
*/
|
|
23
|
+
function elideEnvOverride() {
|
|
24
|
+
const raw = process.env.WEBJS_ELIDE;
|
|
25
|
+
if (raw == null || raw === '') return undefined;
|
|
26
|
+
const v = String(raw).trim().toLowerCase();
|
|
27
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
28
|
+
if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the project-level elision switch.
|
|
34
|
+
* Precedence: the `WEBJS_ELIDE` env override wins when set, otherwise the
|
|
35
|
+
* `package.json` `{ "webjs": { "elide": false } }` switch disables
|
|
36
|
+
* display-only and inert-route elision app-wide (everything ships, like
|
|
37
|
+
* before the feature existed). Any other value, or an absent key, leaves
|
|
38
|
+
* elision enabled (the default). Re-read on every rebuild so toggling
|
|
39
|
+
* either control takes effect without a server restart.
|
|
40
|
+
* @param {string} appDir
|
|
41
|
+
* @returns {Promise<boolean>}
|
|
42
|
+
*/
|
|
43
|
+
export async function readElideEnabled(appDir) {
|
|
44
|
+
const override = elideEnvOverride();
|
|
45
|
+
if (override !== undefined) return override;
|
|
46
|
+
try {
|
|
47
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
48
|
+
if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
|
|
49
|
+
} catch {
|
|
50
|
+
// No package.json, malformed JSON, or unreadable. Keep the default.
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the `WEBJS_SEED` environment override, if set. Same grammar as
|
|
57
|
+
* `WEBJS_ELIDE`: `0`/`false`/`off`/`no` force seeding OFF, `1`/`true`/`on`/`yes`
|
|
58
|
+
* force it ON, anything else (or unset) falls through to the package.json
|
|
59
|
+
* switch. The deploy-time / ops escape hatch and the seam tests use to render
|
|
60
|
+
* an app with seeding on and off in one process.
|
|
61
|
+
* @returns {boolean | undefined}
|
|
62
|
+
*/
|
|
63
|
+
function seedEnvOverride() {
|
|
64
|
+
const raw = process.env.WEBJS_SEED;
|
|
65
|
+
if (raw == null || raw === '') return undefined;
|
|
66
|
+
const v = String(raw).trim().toLowerCase();
|
|
67
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
68
|
+
if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read the project-level SSR action-seeding switch (#472). Precedence: the
|
|
74
|
+
* `WEBJS_SEED` env override wins when set, otherwise the package.json
|
|
75
|
+
* `{ "webjs": { "seed": false } }` switch disables it (the client re-fetches on
|
|
76
|
+
* hydration as it did before the feature). Default ON (opt-out), mirroring
|
|
77
|
+
* elision: any value other than the literal `false` keeps seeding enabled.
|
|
78
|
+
*
|
|
79
|
+
* Seeding installs a process-global `module.registerHooks` load hook, so unlike
|
|
80
|
+
* elision it is read ONCE at boot (not re-read per rebuild): toggling it needs a
|
|
81
|
+
* restart, like a deploy.
|
|
82
|
+
* @param {string} appDir
|
|
83
|
+
* @returns {Promise<boolean>}
|
|
84
|
+
*/
|
|
85
|
+
export async function readSeedEnabled(appDir) {
|
|
86
|
+
const override = seedEnvOverride();
|
|
87
|
+
if (override !== undefined) return override;
|
|
88
|
+
try {
|
|
89
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
90
|
+
if (pkg && pkg.webjs && pkg.webjs.seed === false) return false;
|
|
91
|
+
} catch {
|
|
92
|
+
// No package.json, malformed JSON, or unreadable. Keep the default.
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read the client-router switch (`webjs.clientRouter`) from the app's
|
|
99
|
+
* package.json (#629). Default `true`: the client router auto-enables in the
|
|
100
|
+
* browser whenever `@webjsdev/core` loads (the automatic-navigation thesis).
|
|
101
|
+
* `{ "webjs": { "clientRouter": false } }` opts the WHOLE app out (pure MPA,
|
|
102
|
+
* full-page navigation), which the render path signals to the browser so the
|
|
103
|
+
* core bundle's module-end auto-enable is skipped. Any other value, or an
|
|
104
|
+
* absent key, keeps the router on. Re-read on every rebuild (alongside elide)
|
|
105
|
+
* so toggling it takes effect without a server restart.
|
|
106
|
+
* @param {string} appDir
|
|
107
|
+
* @returns {Promise<boolean>}
|
|
108
|
+
*/
|
|
109
|
+
export async function readClientRouterEnabled(appDir) {
|
|
110
|
+
try {
|
|
111
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
112
|
+
if (pkg && pkg.webjs && pkg.webjs.clientRouter === false) return false;
|
|
113
|
+
} catch {
|
|
114
|
+
// No package.json, malformed JSON, or unreadable. Keep the default.
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read the per-path response-header config (`webjs.headers`) from the
|
|
121
|
+
* app's package.json and compile it to URLPattern rules. A missing,
|
|
122
|
+
* malformed, or unreadable config yields an empty rule set (the secure
|
|
123
|
+
* defaults still apply), never a throw.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} appDir
|
|
126
|
+
* @returns {Promise<ReturnType<typeof compileHeaderRules>>}
|
|
127
|
+
*/
|
|
128
|
+
export async function readHeaderRules(appDir) {
|
|
129
|
+
try {
|
|
130
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
131
|
+
return compileHeaderRules(pkg);
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read the declarative redirect config (`webjs.redirects`) from the app's
|
|
139
|
+
* package.json and compile it to URLPattern rules (issue #254). A missing,
|
|
140
|
+
* malformed, or unreadable config yields an empty rule set (no redirects),
|
|
141
|
+
* never a throw. Patterns are compiled ONCE here at boot, not per request.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} appDir
|
|
144
|
+
* @returns {Promise<ReturnType<typeof compileRedirectRules>>}
|
|
145
|
+
*/
|
|
146
|
+
export async function readRedirectRules(appDir) {
|
|
147
|
+
try {
|
|
148
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
149
|
+
return compileRedirectRules(pkg);
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read the trailing-slash policy (`webjs.trailingSlash`) from the app's
|
|
157
|
+
* package.json (issue #255). A missing, malformed, or unreadable config
|
|
158
|
+
* yields `'ignore'` (no canonicalization), never a throw, so an
|
|
159
|
+
* unconfigured app is unchanged.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} appDir
|
|
162
|
+
* @returns {Promise<'never' | 'always' | 'ignore'>}
|
|
163
|
+
*/
|
|
164
|
+
export async function readTrailingSlashFromApp(appDir) {
|
|
165
|
+
try {
|
|
166
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
167
|
+
return readTrailingSlashPolicy(pkg);
|
|
168
|
+
} catch {
|
|
169
|
+
return 'ignore';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Read the sub-path base path (`webjs.basePath`) from the app's
|
|
175
|
+
* package.json (issue #256). A missing, malformed, or unreadable config
|
|
176
|
+
* yields `''` (root mount), never a throw, so an unconfigured app is
|
|
177
|
+
* byte-identical to before this feature. Normalized to `''` or
|
|
178
|
+
* `/segment[/segment...]` by `readBasePath`.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} appDir
|
|
181
|
+
* @returns {Promise<string>}
|
|
182
|
+
*/
|
|
183
|
+
export async function readBasePathFromApp(appDir) {
|
|
184
|
+
try {
|
|
185
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
186
|
+
return readBasePath(pkg);
|
|
187
|
+
} catch {
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read the app package.json and warn once about anything wrong with its `webjs`
|
|
194
|
+
* block (#1300). One aggregated warning, listing every problem, so a config with
|
|
195
|
+
* three typos does not produce three log lines.
|
|
196
|
+
*
|
|
197
|
+
* Never throws and never exits. See `webjs-config-validate.js` for why warning
|
|
198
|
+
* is the ruling and why this does not replace the CLI's `doctor.gate` check.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} appDir
|
|
201
|
+
* @param {{ warn?: (...args: any[]) => void }} logger
|
|
202
|
+
* @returns {Promise<void>}
|
|
203
|
+
*/
|
|
204
|
+
export async function warnOnInvalidWebjsConfig(appDir, logger) {
|
|
205
|
+
let problems;
|
|
206
|
+
try {
|
|
207
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
208
|
+
problems = validateAppWebjsConfig(pkg);
|
|
209
|
+
} catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!problems.length) return;
|
|
213
|
+
logger?.warn?.(
|
|
214
|
+
`[webjs] the "webjs" block in package.json has ${problems.length} problem(s), ` +
|
|
215
|
+
`each ignored at its default: ${problems.join('; ')}. ` +
|
|
216
|
+
`See https://webjs.dev/docs/configuration`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Read the cross-origin allowlist (`webjs.allowedOrigins`) from the app's
|
|
222
|
+
* package.json. These hosts / origins are accepted by the action CSRF check
|
|
223
|
+
* even when cross-site (reverse-proxy / multi-domain setups). A missing or
|
|
224
|
+
* unreadable config yields `[]`.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} appDir
|
|
227
|
+
* @returns {Promise<string[]>}
|
|
228
|
+
*/
|
|
229
|
+
export async function readAllowedOriginsFromApp(appDir) {
|
|
230
|
+
try {
|
|
231
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
232
|
+
return readAllowedOrigins(pkg);
|
|
233
|
+
} catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Read the CSP config (`webjs.csp`) from the app's package.json and
|
|
240
|
+
* normalize it (issue #233). A missing, malformed, or unreadable config
|
|
241
|
+
* yields a disabled config (no nonce minted, no CSP header), never a
|
|
242
|
+
* throw: a broken security knob must fail closed, not take the app down.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} appDir
|
|
245
|
+
* @returns {Promise<ReturnType<typeof readCspConfig>>}
|
|
246
|
+
*/
|
|
247
|
+
export async function readCspConfigFromApp(appDir) {
|
|
248
|
+
try {
|
|
249
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
250
|
+
return readCspConfig(pkg);
|
|
251
|
+
} catch {
|
|
252
|
+
return readCspConfig(undefined);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Resolve the request body-size limits (issue #237) from the app's package.json
|
|
258
|
+
* `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env overrides
|
|
259
|
+
* (`WEBJS_MAX_BODY_BYTES` / `WEBJS_MAX_MULTIPART_BYTES`). A missing or
|
|
260
|
+
* unreadable package.json falls through to the secure defaults (env still wins),
|
|
261
|
+
* never a throw.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} appDir
|
|
264
|
+
* @returns {Promise<{ json: number, multipart: number }>}
|
|
265
|
+
*/
|
|
266
|
+
export async function readBodyLimitsFromApp(appDir) {
|
|
267
|
+
let pkg;
|
|
268
|
+
try {
|
|
269
|
+
pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
270
|
+
} catch {
|
|
271
|
+
pkg = undefined;
|
|
272
|
+
}
|
|
273
|
+
return readBodyLimits(pkg);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Extra dev watch roots from the app's package.json `webjs.dev.watch` (#894).
|
|
278
|
+
* These are directories the app READS from but that live OUTSIDE its appDir, so
|
|
279
|
+
* the recursive `fs.watch(appDir)` never sees them (e.g. the website renders
|
|
280
|
+
* posts from a repo-root `blog/` dir, a sibling of the app, so editing a post
|
|
281
|
+
* would not live-reload). Each entry is resolved relative to the appDir and MAY
|
|
282
|
+
* escape it (`"../blog"`); a change under one runs the same rebuild + reload as
|
|
283
|
+
* an in-tree edit. Opt-in: a missing/empty config yields `[]`, so a plain app
|
|
284
|
+
* watches only its appDir, unchanged.
|
|
285
|
+
*
|
|
286
|
+
* Returns absolute, de-duped paths, skipping any that overlap the appDir (the
|
|
287
|
+
* appDir is already watched recursively, so an ancestor or descendant root
|
|
288
|
+
* would just double-fire the rebuild). Existence is NOT checked here (the caller
|
|
289
|
+
* filters missing paths so it can log them); the reader stays a pure
|
|
290
|
+
* package.json read, matching the other `readXFromApp` helpers.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} appDir
|
|
293
|
+
* @returns {Promise<string[]>}
|
|
294
|
+
*/
|
|
295
|
+
export async function readDevWatchPathsFromApp(appDir) {
|
|
296
|
+
let pkg;
|
|
297
|
+
try {
|
|
298
|
+
pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
299
|
+
} catch {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
const raw = pkg && pkg.webjs && pkg.webjs.dev && pkg.webjs.dev.watch;
|
|
303
|
+
if (!Array.isArray(raw)) return [];
|
|
304
|
+
/** @type {string[]} */
|
|
305
|
+
const out = [];
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
for (const entry of raw) {
|
|
308
|
+
if (typeof entry !== 'string' || !entry.trim()) continue;
|
|
309
|
+
const abs = resolve(appDir, entry);
|
|
310
|
+
// Skip the appDir itself and any ancestor/descendant overlap: the appDir is
|
|
311
|
+
// already watched recursively, so an overlapping extra root double-fires.
|
|
312
|
+
if (abs === appDir || appDir.startsWith(abs + sep) || abs.startsWith(appDir + sep)) continue;
|
|
313
|
+
if (seen.has(abs)) continue;
|
|
314
|
+
seen.add(abs);
|
|
315
|
+
out.push(abs);
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Resolve the node:http server timeouts (issue #237) from the app's
|
|
322
|
+
* package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
|
|
323
|
+
* `webjs.keepAliveTimeoutMs` plus the env overrides. A missing or unreadable
|
|
324
|
+
* package.json falls through to the secure defaults (env still wins).
|
|
325
|
+
*
|
|
326
|
+
* @param {string} appDir
|
|
327
|
+
* @returns {Promise<{ requestTimeout: number, headersTimeout: number, keepAliveTimeout: number }>}
|
|
328
|
+
*/
|
|
329
|
+
export async function readServerTimeoutsFromApp(appDir) {
|
|
330
|
+
let pkg;
|
|
331
|
+
try {
|
|
332
|
+
pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
333
|
+
} catch {
|
|
334
|
+
pkg = undefined;
|
|
335
|
+
}
|
|
336
|
+
return computeServerTimeouts(pkg);
|
|
337
|
+
}
|
|
338
|
+
|