@webjsdev/server 0.8.60 → 0.8.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/actions.js +1 -1
- package/src/base-path.js +10 -0
- package/src/check.js +8 -19
- package/src/component-elision.js +8 -3
- package/src/dev.js +49 -1
- package/src/form-dispatch.js +1 -1
- package/src/headers.js +78 -15
- package/src/js-scan.js +49 -2
- package/src/redirects.js +19 -8
- package/src/ssr.js +63 -4
- package/src/webjs-config-validate.js +123 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.62",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@webjsdev/core": "^0.7.
|
|
31
|
+
"@webjsdev/core": "^0.7.50",
|
|
32
32
|
"ws": "^8.20.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/actions.js
CHANGED
|
@@ -245,7 +245,7 @@ export async function isServerAction(file) {
|
|
|
245
245
|
|
|
246
246
|
/**
|
|
247
247
|
* @param {ActionIndex} idx
|
|
248
|
-
* @param {string} urlPath
|
|
248
|
+
* @param {string} urlPath A browser-visible URL path like `/actions/foo.server.js`
|
|
249
249
|
*/
|
|
250
250
|
export function resolveServerModule(idx, urlPath) {
|
|
251
251
|
const abs = join(idx.appDir, urlPath.split('/').join(sep));
|
package/src/base-path.js
CHANGED
|
@@ -54,6 +54,16 @@
|
|
|
54
54
|
* or a hostile value fails safe to "no base path" rather than poisoning
|
|
55
55
|
* every emitted URL.
|
|
56
56
|
*
|
|
57
|
+
* This function is PORTED into the CLI, as `readAppBasePath`
|
|
58
|
+
* (`packages/cli/lib/doctor.js`), because doctor must run when
|
|
59
|
+
* `@webjsdev/server` does not resolve from the app dir at all (#954). The port
|
|
60
|
+
* is deliberate, so the two are kept honest by
|
|
61
|
+
* `test/cli/base-path-parity.test.mjs`, which runs one input table through both
|
|
62
|
+
* and asserts they agree. Edit this body and that test reds until the port
|
|
63
|
+
* follows. The ordering below matters most: the `//host` rejection sits BEFORE
|
|
64
|
+
* the leading-slash collapse, and reordering it on either side is an origin
|
|
65
|
+
* escape the parity table catches.
|
|
66
|
+
*
|
|
57
67
|
* @param {unknown} raw the configured value
|
|
58
68
|
* @returns {string} `''` or `/segment[/segment...]`
|
|
59
69
|
*/
|
package/src/check.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
extractWebComponentClassBodies,
|
|
8
8
|
matchClosingBrace,
|
|
9
9
|
parsePropEntries,
|
|
10
|
+
classifyActionHole,
|
|
10
11
|
} from './js-scan.js';
|
|
11
12
|
import { buildModuleGraph, transitiveDeps, resolveImport } from './module-graph.js';
|
|
12
13
|
import { scanComponents } from './component-scanner.js';
|
|
@@ -190,7 +191,7 @@ function isServerActionFile(filePath, content) {
|
|
|
190
191
|
/**
|
|
191
192
|
* Check whether a file resides under a components/ directory (shared or
|
|
192
193
|
* module-scoped).
|
|
193
|
-
* @param {string} relPath
|
|
194
|
+
* @param {string} relPath Path relative to appDir
|
|
194
195
|
* @returns {boolean}
|
|
195
196
|
*/
|
|
196
197
|
function isComponentFile(relPath) {
|
|
@@ -1396,26 +1397,14 @@ export async function checkConventions(appDir) {
|
|
|
1396
1397
|
// with no hole in it. That distinction is the whole carve-out: the
|
|
1397
1398
|
// framework's own website renders this exact shape as a code sample.
|
|
1398
1399
|
const { redacted, literals } = redactToPlaceholders(content);
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
// attribute are matched as a PAIR, so `<form formaction=${x}>` and
|
|
1405
|
-
// `<button action=${x}>` stay out too: both are refused at render time,
|
|
1406
|
-
// and neither is this rule's failure mode.
|
|
1400
|
+
// Both binding shapes count the same here: `action=` on a `<form>`
|
|
1401
|
+
// (#1155) and `formaction=` on a `<button>` / `<input>` submitter
|
|
1402
|
+
// (#1207) each put the named action behind a form submission, and a
|
|
1403
|
+
// GET-declared action cannot serve either. `classifyActionHole` owns
|
|
1404
|
+
// the tag-and-attribute pairing (js-scan.js).
|
|
1407
1405
|
const bound = new Set();
|
|
1408
1406
|
for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
|
|
1409
|
-
|
|
1410
|
-
// The last `<` opens the tag the hole sits in. Requiring no `>`
|
|
1411
|
-
// after it keeps the match inside that one start tag.
|
|
1412
|
-
const tagAt = before.lastIndexOf('<');
|
|
1413
|
-
if (tagAt < 0) continue;
|
|
1414
|
-
const tag = before.slice(tagAt);
|
|
1415
|
-
if (tag.includes('>')) continue;
|
|
1416
|
-
const bindsForm = /^<form\b/i.test(tag) && /\saction=$/i.test(tag);
|
|
1417
|
-
const bindsSubmitter = /^<(?:button|input)\b/i.test(tag) && /\sformaction=$/i.test(tag);
|
|
1418
|
-
if (!bindsForm && !bindsSubmitter) continue;
|
|
1407
|
+
if (!classifyActionHole(literals[Number(m[1])] || '')) continue;
|
|
1419
1408
|
bound.add(m[2]);
|
|
1420
1409
|
}
|
|
1421
1410
|
if (!bound.size) continue;
|
package/src/component-elision.js
CHANGED
|
@@ -238,8 +238,13 @@ const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
|
|
|
238
238
|
|
|
239
239
|
/** Side-effect or named import of the client router subpath. */
|
|
240
240
|
const CLIENT_ROUTER_SUBPATH_RE = /['"]@webjsdev\/core\/client-router['"]/;
|
|
241
|
-
/**
|
|
242
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Client-only named APIs from the `@webjsdev/core` main entry. Exported so
|
|
243
|
+
* `test/elision/lifecycle-coverage.test.js` can guard it the way it guards
|
|
244
|
+
* REACTIVE_IMPORTS: a router function the barrel re-exports but this list omits
|
|
245
|
+
* is not recognized as client work, so a component using it is wrongly elided.
|
|
246
|
+
*/
|
|
247
|
+
export const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate', 'loadFrame'];
|
|
243
248
|
|
|
244
249
|
/** Identifiers that only exist in a browser; their presence means client work. */
|
|
245
250
|
const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
|
|
@@ -313,7 +318,7 @@ const PURE_DATA_CONSTRUCTORS = new Set([
|
|
|
313
318
|
'BigInt64Array', 'BigUint64Array',
|
|
314
319
|
]);
|
|
315
320
|
|
|
316
|
-
function hasModuleScopeSideEffect(src, literals) {
|
|
321
|
+
export function hasModuleScopeSideEffect(src, literals) {
|
|
317
322
|
let redacted = src;
|
|
318
323
|
if (!literals) {
|
|
319
324
|
const r = redactToPlaceholders(src);
|
package/src/dev.js
CHANGED
|
@@ -14,7 +14,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
14
14
|
|
|
15
15
|
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
16
16
|
import { generateRouteTypes } from './route-types.js';
|
|
17
|
-
import { ssrPage, ssrNotFound, setClientRouterEnabled } from './ssr.js';
|
|
17
|
+
import { ssrPage, ssrNotFound, setClientRouterEnabled, setMetadataIconRoutes } from './ssr.js';
|
|
18
18
|
import { runFormAction, reportFormSubmittedAsGet } from './form-dispatch.js';
|
|
19
19
|
import { handleApi } from './api.js';
|
|
20
20
|
import {
|
|
@@ -91,6 +91,7 @@ function shouldAccessLog(pathname) {
|
|
|
91
91
|
}
|
|
92
92
|
import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
|
|
93
93
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
94
|
+
import { validateAppWebjsConfig } from './webjs-config-validate.js';
|
|
94
95
|
import { propagateTrustedRemoteIp } from './rate-limit.js';
|
|
95
96
|
import { readAllowedOrigins } from './csrf.js';
|
|
96
97
|
import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
|
|
@@ -370,6 +371,34 @@ export async function readBasePathFromApp(appDir) {
|
|
|
370
371
|
}
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Read the app package.json and warn once about anything wrong with its `webjs`
|
|
376
|
+
* block (#1300). One aggregated warning, listing every problem, so a config with
|
|
377
|
+
* three typos does not produce three log lines.
|
|
378
|
+
*
|
|
379
|
+
* Never throws and never exits. See `webjs-config-validate.js` for why warning
|
|
380
|
+
* is the ruling and why this does not replace the CLI's `doctor.gate` check.
|
|
381
|
+
*
|
|
382
|
+
* @param {string} appDir
|
|
383
|
+
* @param {{ warn?: (...args: any[]) => void }} logger
|
|
384
|
+
* @returns {Promise<void>}
|
|
385
|
+
*/
|
|
386
|
+
export async function warnOnInvalidWebjsConfig(appDir, logger) {
|
|
387
|
+
let problems;
|
|
388
|
+
try {
|
|
389
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
390
|
+
problems = validateAppWebjsConfig(pkg);
|
|
391
|
+
} catch {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!problems.length) return;
|
|
395
|
+
logger?.warn?.(
|
|
396
|
+
`[webjs] the "webjs" block in package.json has ${problems.length} problem(s), ` +
|
|
397
|
+
`each ignored at its default: ${problems.join('; ')}. ` +
|
|
398
|
+
`See https://webjs.dev/docs/configuration`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
373
402
|
/**
|
|
374
403
|
* Read the cross-origin allowlist (`webjs.allowedOrigins`) from the app's
|
|
375
404
|
* package.json. These hosts / origins are accepted by the action CSRF check
|
|
@@ -532,6 +561,17 @@ export async function createRequestHandler(opts) {
|
|
|
532
561
|
await applyEnvValidation(appDir, { dev: !!opts.dev });
|
|
533
562
|
const dev = !!opts.dev;
|
|
534
563
|
const logger = opts.logger || defaultLogger({ dev });
|
|
564
|
+
// Boot-time `webjs` config validation (#1300). The published JSON Schema used
|
|
565
|
+
// to reach users only through the scaffold's .vscode `$ref`, so a typo'd key
|
|
566
|
+
// was caught in an editor and nowhere else: the key was dropped, the feature
|
|
567
|
+
// stayed at its default, and nothing said so. This runs it once per boot, from
|
|
568
|
+
// the one entry point dev, prod, and an embedded host all share.
|
|
569
|
+
//
|
|
570
|
+
// It WARNS and continues, never throws. A typo costs one feature sitting at
|
|
571
|
+
// its default; a hard boot failure over a schema quibble costs the whole app.
|
|
572
|
+
// A missing / unreadable / unparseable package.json is a silent no-op, like
|
|
573
|
+
// every other `webjs.*` reader in this file.
|
|
574
|
+
await warnOnInvalidWebjsConfig(appDir, logger);
|
|
535
575
|
// Boot-time instrumentation hook (#848): run the optional app-root
|
|
536
576
|
// instrumentation.{js,ts} register() ONCE, after env validation and before
|
|
537
577
|
// the route table / action index are built, so observability plumbing starts
|
|
@@ -702,6 +742,12 @@ export async function createRequestHandler(opts) {
|
|
|
702
742
|
// like env.js / readiness.js), not a router stem. Resolve it once and stash it
|
|
703
743
|
// on the route table so ssrOpts + the browser-servable gate reach it uniformly.
|
|
704
744
|
routeTable.instrumentationClient = await findInstrumentationClient(appDir);
|
|
745
|
+
// Auto-linked favicons: tell the head builder which icon metadata routes
|
|
746
|
+
// exist, so `app/icon.*` is linked when the app declares no metadata.icons.
|
|
747
|
+
// Bound here rather than threaded through ssrOpts, matching
|
|
748
|
+
// setClientRouterEnabled; re-bound in doRebuild so adding or deleting the
|
|
749
|
+
// file takes effect without a restart.
|
|
750
|
+
setMetadataIconRoutes(routeTable.metadataRoutes);
|
|
705
751
|
|
|
706
752
|
// Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
|
|
707
753
|
// dev so an editor's tsserver always has up-to-date route types without the
|
|
@@ -1167,6 +1213,8 @@ export async function createRequestHandler(opts) {
|
|
|
1167
1213
|
// it so routing reflects added/removed route files immediately.
|
|
1168
1214
|
state.routeTable = await buildRouteTable(appDir);
|
|
1169
1215
|
state.routeTable.instrumentationClient = await findInstrumentationClient(appDir);
|
|
1216
|
+
// Adding or deleting app/icon.* changes whether the head auto-links it.
|
|
1217
|
+
setMetadataIconRoutes(state.routeTable.metadataRoutes);
|
|
1170
1218
|
// Refresh the generated route types (#258) so adding/removing a route file
|
|
1171
1219
|
// updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
|
|
1172
1220
|
// best-effort (see emitRouteTypes).
|
package/src/form-dispatch.js
CHANGED
|
@@ -359,7 +359,7 @@ export function reportFormSubmittedAsGet(url, req, onError, logger, dev, route)
|
|
|
359
359
|
}
|
|
360
360
|
if (!willReport) return;
|
|
361
361
|
const err = new Error(
|
|
362
|
-
`A form submission reached ${url.pathname} as a GET with the \`${FORM_ACTION_FIELD}\` identity in the query string, so no server action ran.
|
|
362
|
+
`A form submission reached ${url.pathname} as a GET with the \`${FORM_ACTION_FIELD}\` identity in the query string, so no server action ran. A BOUND submitter carries its own formmethod="post" and a bound form is refused a method="get" outright, so look instead for a PLAIN submitter's formmethod="get", or a hand-authored form carrying the reserved field: a plain submitter's own formmethod wins by native precedence and WebJs honours it rather than refusing it.`,
|
|
363
363
|
);
|
|
364
364
|
/** @type {any} */ (err).code = 'WEBJS_FORM_SUBMITTED_AS_GET';
|
|
365
365
|
/** @type {any} */ (err).method = req.method;
|
package/src/headers.js
CHANGED
|
@@ -53,7 +53,10 @@ const HSTS_VALUE = 'max-age=63072000; includeSubDomains';
|
|
|
53
53
|
* (`webjs.headers`). Returns a normalized array of compiled rules, each
|
|
54
54
|
* pairing a URLPattern against the configured header directives. A
|
|
55
55
|
* malformed or absent config yields an empty array (no per-path rules),
|
|
56
|
-
* never a throw: a broken config must not take the app down.
|
|
56
|
+
* never a throw: a broken config must not take the app down. Each malformed
|
|
57
|
+
* rule or directive is DROPPED with a one-line warning, matching
|
|
58
|
+
* `redirects.js`, so a single typo neither disables the valid rules around it
|
|
59
|
+
* nor disappears without a word.
|
|
57
60
|
*
|
|
58
61
|
* Shape consumed:
|
|
59
62
|
* "webjs": { "headers": [ { "source": "/embed/:path*",
|
|
@@ -67,33 +70,61 @@ const HSTS_VALUE = 'max-age=63072000; includeSubDomains';
|
|
|
67
70
|
* @returns {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>}
|
|
68
71
|
*/
|
|
69
72
|
export function compileHeaderRules(pkg) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
// Read the block explicitly rather than off an `&&` chain. A chain SHORT-
|
|
74
|
+
// CIRCUITS, so its value is whatever link failed (a non-object `pkg` yields
|
|
75
|
+
// the boolean `false` from the `typeof` test), and deciding "the key is
|
|
76
|
+
// present" from that residue warns about a `headers` nobody wrote and prints
|
|
77
|
+
// a value that is not in their config.
|
|
78
|
+
const block = pkg && typeof pkg === 'object' ? /** @type {any} */ (pkg).webjs : undefined;
|
|
79
|
+
const hasBlock = !!block && typeof block === 'object' && !Array.isArray(block);
|
|
80
|
+
const raw = hasBlock ? block.headers : undefined;
|
|
81
|
+
if (!Array.isArray(raw)) {
|
|
82
|
+
// An ABSENT key is the default and says nothing. A PRESENT one of the wrong
|
|
83
|
+
// type discards the whole config, so it says so: the schema types this key
|
|
84
|
+
// `array` but the boot config check only inspects boolean / integer / enum
|
|
85
|
+
// leaves, so `"headers": {…}` is caught by nothing else.
|
|
86
|
+
if (hasBlock && 'headers' in block) warnDrop('headers must be an array', raw);
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
76
89
|
/** @type {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>} */
|
|
77
90
|
const rules = [];
|
|
78
91
|
for (const entry of raw) {
|
|
79
|
-
if (!entry || typeof entry !== 'object')
|
|
92
|
+
if (!entry || typeof entry !== 'object') {
|
|
93
|
+
warnDrop('entry must be an object', entry);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
80
96
|
const source = /** @type {any} */ (entry).source;
|
|
81
97
|
const list = /** @type {any} */ (entry).headers;
|
|
82
|
-
if (typeof source !== 'string' || !
|
|
98
|
+
if (typeof source !== 'string' || !source) {
|
|
99
|
+
warnDrop('source must be a non-empty string', entry);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(list)) {
|
|
103
|
+
warnDrop(`headers must be an array on "${source}"`, entry);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
83
106
|
let pattern;
|
|
84
107
|
try {
|
|
85
108
|
// Match on the pathname only. A bare path string is the common
|
|
86
109
|
// Next-style usage; URLPattern treats it as the pathname component.
|
|
87
110
|
pattern = new URLPattern({ pathname: source });
|
|
88
111
|
} catch {
|
|
89
|
-
|
|
112
|
+
// Skip an invalid pattern rather than crash the request.
|
|
113
|
+
warnDrop(`invalid source pattern "${source}"`, entry);
|
|
114
|
+
continue;
|
|
90
115
|
}
|
|
91
116
|
/** @type {Array<{ key: string, value: string | null }>} */
|
|
92
117
|
const directives = [];
|
|
93
118
|
for (const d of list) {
|
|
94
|
-
if (!d || typeof d !== 'object')
|
|
119
|
+
if (!d || typeof d !== 'object') {
|
|
120
|
+
warnDrop(`directive must be an object on "${source}"`, d);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
95
123
|
const key = /** @type {any} */ (d).key;
|
|
96
|
-
if (typeof key !== 'string' || !key)
|
|
124
|
+
if (typeof key !== 'string' || !key) {
|
|
125
|
+
warnDrop(`directive key must be a non-empty string on "${source}"`, d);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
97
128
|
const v = /** @type {any} */ (d).value;
|
|
98
129
|
// null / undefined / false means REMOVE the header on a match.
|
|
99
130
|
const value = v === null || v === undefined || v === false ? null : String(v);
|
|
@@ -109,17 +140,49 @@ export function compileHeaderRules(pkg) {
|
|
|
109
140
|
try {
|
|
110
141
|
new Headers().set(key, value === null ? 'x' : value);
|
|
111
142
|
} catch {
|
|
112
|
-
|
|
113
|
-
console.warn(`[webjs] dropping invalid webjs.headers directive for key "${key}"`);
|
|
143
|
+
warnDrop(`invalid header name or value for key "${key}"`, d);
|
|
114
144
|
continue;
|
|
115
145
|
}
|
|
116
146
|
directives.push({ key, value });
|
|
117
147
|
}
|
|
118
|
-
|
|
148
|
+
// A rule with nothing left to apply is dropped, and SAYS so. Two ways to
|
|
149
|
+
// get here and both were silent: an authored `"headers": []`, which the
|
|
150
|
+
// schema permits (no `minItems`) and the boot config check never sees
|
|
151
|
+
// (it does not descend into `headers`), and a rule whose every directive
|
|
152
|
+
// was just dropped, where the per-directive warnings above name the
|
|
153
|
+
// directives but never say the rule itself went with them.
|
|
154
|
+
if (!directives.length) {
|
|
155
|
+
warnDrop(`no valid directives left on "${source}"`, entry);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
rules.push({ pattern, directives });
|
|
119
159
|
}
|
|
120
160
|
return rules;
|
|
121
161
|
}
|
|
122
162
|
|
|
163
|
+
/**
|
|
164
|
+
* One line per dropped config, rule, or directive, matching `redirects.js`'s
|
|
165
|
+
* warnDrop verbatim in shape. Every drop in `compileHeaderRules` goes through
|
|
166
|
+
* this, at all three levels: a wrong-typed `webjs.headers` key, a rule, and a
|
|
167
|
+
* directive. A config typo that silently does nothing is the failure this whole
|
|
168
|
+
* surface exists to end, and a `source` misspelled as `sources` used to vanish
|
|
169
|
+
* here with no diagnostic at all.
|
|
170
|
+
*
|
|
171
|
+
* The one thing that does NOT warn is an ABSENT `webjs.headers`, which is the
|
|
172
|
+
* default rather than a mistake.
|
|
173
|
+
*
|
|
174
|
+
* Defined BELOW `compileHeaderRules`, like the one in `redirects.js`. Putting
|
|
175
|
+
* it above wedged this docblock between that function's JSDoc and its
|
|
176
|
+
* declaration, so the JSDoc bound to the helper instead and the reader lost
|
|
177
|
+
* its `@param` / `@returns` entirely.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} reason @param {unknown} entry
|
|
180
|
+
*/
|
|
181
|
+
function warnDrop(reason, entry) {
|
|
182
|
+
// eslint-disable-next-line no-console
|
|
183
|
+
console.warn(`[webjs] dropping invalid webjs.headers config (${reason}):`, entry);
|
|
184
|
+
}
|
|
185
|
+
|
|
123
186
|
/**
|
|
124
187
|
* Apply the security defaults and the per-path config to a Response,
|
|
125
188
|
* returning a Response carrying the merged headers. The input Response
|
package/src/js-scan.js
CHANGED
|
@@ -470,7 +470,13 @@ export function extractWebComponentClassBodies(content) {
|
|
|
470
470
|
bodies.push({
|
|
471
471
|
body: content.slice(bodyStart, end),
|
|
472
472
|
factoryProps,
|
|
473
|
-
factoryArg
|
|
473
|
+
factoryArg,
|
|
474
|
+
// Offsets into `content`. Callers pass the position-preserving MASK,
|
|
475
|
+
// so these index the RAW source identically, which is how a caller
|
|
476
|
+
// that needs the body's real template text gets it without asking
|
|
477
|
+
// this lexer to handle raw source (#1307).
|
|
478
|
+
bodyStart,
|
|
479
|
+
bodyEnd: end,
|
|
474
480
|
});
|
|
475
481
|
}
|
|
476
482
|
}
|
|
@@ -640,7 +646,7 @@ export function matchClosingParenthesis(s, start) {
|
|
|
640
646
|
* (the closing `}` arrived while still in template state), so depth could
|
|
641
647
|
* never return to zero and a class body holding `` html`…${x}…` `` was
|
|
642
648
|
* unmatchable. Every caller passes a masked source in which holes are already
|
|
643
|
-
* blanked, so the bug is invisible until one passes raw source.
|
|
649
|
+
* blanked, so the bug is invisible until one passes raw source (#1307).
|
|
644
650
|
*
|
|
645
651
|
* @param {string} s
|
|
646
652
|
* @param {number} start
|
|
@@ -717,3 +723,44 @@ export function redactToPlaceholders(src) {
|
|
|
717
723
|
return { redacted: out, literals };
|
|
718
724
|
}
|
|
719
725
|
|
|
726
|
+
/**
|
|
727
|
+
* The attribute position a template hole commits to, read from the literal
|
|
728
|
+
* segment immediately before it. Returns the attribute NAME only; a caller
|
|
729
|
+
* pairs it with the tag the hole sits in.
|
|
730
|
+
*
|
|
731
|
+
* @param {string} literalBefore
|
|
732
|
+
* @returns {'action' | 'formaction' | null}
|
|
733
|
+
*/
|
|
734
|
+
function trailingActionAttr(literalBefore) {
|
|
735
|
+
if (/\sformaction=$/i.test(literalBefore)) return 'formaction';
|
|
736
|
+
if (/\saction=$/i.test(literalBefore)) return 'action';
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Classify a template hole by the start tag and attribute it commits: `'form'`
|
|
742
|
+
* for `<form action=` (#1155), `'submitter'` for `<button|input formaction=`
|
|
743
|
+
* (#1207), and `null` for anything else.
|
|
744
|
+
*
|
|
745
|
+
* Reading the segment immediately before the hole rather than the whole
|
|
746
|
+
* template is what keeps `<div action=${x}>` out. The tag and the attribute are
|
|
747
|
+
* matched as a PAIR, so `<form formaction=${x}>` and `<button action=${x}>`
|
|
748
|
+
* stay out too: both are refused at render time, and neither is a binding.
|
|
749
|
+
*
|
|
750
|
+
* @param {string} literalBefore the literal segment immediately before the hole
|
|
751
|
+
* @returns {'form' | 'submitter' | null}
|
|
752
|
+
*/
|
|
753
|
+
export function classifyActionHole(literalBefore) {
|
|
754
|
+
const attr = trailingActionAttr(literalBefore);
|
|
755
|
+
if (!attr) return null;
|
|
756
|
+
// The last `<` opens the tag the hole sits in. Requiring no `>` after it
|
|
757
|
+
// keeps the match inside that one start tag.
|
|
758
|
+
const tagAt = literalBefore.lastIndexOf('<');
|
|
759
|
+
if (tagAt < 0) return null;
|
|
760
|
+
const tag = literalBefore.slice(tagAt);
|
|
761
|
+
if (tag.includes('>')) return null;
|
|
762
|
+
if (attr === 'action' && /^<form\b/i.test(tag)) return 'form';
|
|
763
|
+
if (attr === 'formaction' && /^<(?:button|input)\b/i.test(tag)) return 'submitter';
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
|
package/src/redirects.js
CHANGED
|
@@ -231,16 +231,27 @@ const ALLOWED_STATUS = new Set([301, 302, 303, 307, 308]);
|
|
|
231
231
|
* @returns {Array<{ pattern: URLPattern, destination: string, status: number }>}
|
|
232
232
|
*/
|
|
233
233
|
export function compileRedirectRules(pkg) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
234
|
+
// Read the block explicitly, not off an `&&` chain, for the reason spelled
|
|
235
|
+
// out in `headers.js`: a chain short-circuits to whatever link failed, so
|
|
236
|
+
// deciding key presence from it warns about a `redirects` nobody wrote.
|
|
237
|
+
const block = pkg && typeof pkg === 'object' ? /** @type {any} */ (pkg).webjs : undefined;
|
|
238
|
+
const hasBlock = !!block && typeof block === 'object' && !Array.isArray(block);
|
|
239
|
+
const raw = hasBlock ? block.redirects : undefined;
|
|
240
|
+
if (!Array.isArray(raw)) {
|
|
241
|
+
// An ABSENT key is the default and says nothing. A PRESENT one of the wrong
|
|
242
|
+
// type discards the whole config, so it says so, matching `headers.js`.
|
|
243
|
+
// Nothing else reports it: the schema types this key `array` and the boot
|
|
244
|
+
// config check inspects only scalar leaves.
|
|
245
|
+
if (hasBlock && 'redirects' in block) warnDrop('redirects must be an array', raw);
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
240
248
|
/** @type {Array<{ pattern: URLPattern, destination: string, status: number }>} */
|
|
241
249
|
const rules = [];
|
|
242
250
|
for (const entry of raw) {
|
|
243
|
-
if (!entry || typeof entry !== 'object')
|
|
251
|
+
if (!entry || typeof entry !== 'object') {
|
|
252
|
+
warnDrop('entry must be an object', entry);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
244
255
|
const source = /** @type {any} */ (entry).source;
|
|
245
256
|
const destination = /** @type {any} */ (entry).destination;
|
|
246
257
|
if (typeof source !== 'string' || !source) {
|
|
@@ -294,7 +305,7 @@ function resolveStatus(entry) {
|
|
|
294
305
|
/** @param {string} reason @param {unknown} entry */
|
|
295
306
|
function warnDrop(reason, entry) {
|
|
296
307
|
// eslint-disable-next-line no-console
|
|
297
|
-
console.warn(`[webjs] dropping invalid webjs.redirects
|
|
308
|
+
console.warn(`[webjs] dropping invalid webjs.redirects config (${reason}):`, entry);
|
|
298
309
|
}
|
|
299
310
|
|
|
300
311
|
/**
|
package/src/ssr.js
CHANGED
|
@@ -1360,6 +1360,54 @@ let _clientRouterEnabled = true;
|
|
|
1360
1360
|
export function setClientRouterEnabled(enabled) { _clientRouterEnabled = enabled !== false; }
|
|
1361
1361
|
function clientRouterEnabled() { return _clientRouterEnabled; }
|
|
1362
1362
|
|
|
1363
|
+
// Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
|
|
1364
|
+
// Set at boot and on each route rebuild from the route table, the same shape
|
|
1365
|
+
// as setClientRouterEnabled above, so no opt has to thread through every
|
|
1366
|
+
// render path. Empty by default, which keeps an app that declares its icons
|
|
1367
|
+
// (or has neither route) byte-identical.
|
|
1368
|
+
/** @type {{ icon: boolean, apple: boolean }} */
|
|
1369
|
+
let _metadataIconRoutes = { icon: false, apple: false };
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Record the icon metadata routes the app defines.
|
|
1373
|
+
*
|
|
1374
|
+
* @param {Iterable<{ stem: string }> | null | undefined} metadataRoutes
|
|
1375
|
+
* The route table's `metadataRoutes`, or nullish to clear.
|
|
1376
|
+
*/
|
|
1377
|
+
export function setMetadataIconRoutes(metadataRoutes) {
|
|
1378
|
+
const stems = new Set();
|
|
1379
|
+
for (const r of metadataRoutes || []) if (r && r.stem) stems.add(r.stem);
|
|
1380
|
+
_metadataIconRoutes = { icon: stems.has('icon'), apple: stems.has('apple-icon') };
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* The implicit `metadata.icons` an app's icon routes stand for, or null when
|
|
1385
|
+
* it has none. Base-path prefixed, because that is where the routes are
|
|
1386
|
+
* SERVED: the listener strips the base path before matching, so under
|
|
1387
|
+
* `webjs.basePath` the route answers at `<basePath>/icon`. A user-authored
|
|
1388
|
+
* `icons` URL is deliberately left alone (it may be cross-origin, and the
|
|
1389
|
+
* author writes the path they mean), so only these framework-emitted ones
|
|
1390
|
+
* are prefixed.
|
|
1391
|
+
*
|
|
1392
|
+
* No `type` or `sizes` is emitted. A metadata route picks its own content
|
|
1393
|
+
* type at request time, which is the reason to use one, so declaring a type
|
|
1394
|
+
* here could contradict the bytes; and `sizes` is unknowable without reading
|
|
1395
|
+
* the response. Both are optional in HTML, and a browser sniffs the served
|
|
1396
|
+
* content type.
|
|
1397
|
+
*
|
|
1398
|
+
* @returns {{ icon?: string, apple?: string } | null}
|
|
1399
|
+
*/
|
|
1400
|
+
function autoMetadataRouteIcons() {
|
|
1401
|
+
const { icon, apple } = _metadataIconRoutes;
|
|
1402
|
+
if (!icon && !apple) return null;
|
|
1403
|
+
const bp = basePath();
|
|
1404
|
+
/** @type {{ icon?: string, apple?: string }} */
|
|
1405
|
+
const out = {};
|
|
1406
|
+
if (icon) out.icon = withBasePath('/icon', bp);
|
|
1407
|
+
if (apple) out.apple = withBasePath('/apple-icon', bp);
|
|
1408
|
+
return out;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1363
1411
|
function wrapHead(opts) {
|
|
1364
1412
|
// CSP nonce: if provided, all inline <script> tags get nonce="…" so they
|
|
1365
1413
|
// pass strict Content-Security-Policy headers. The nonce is extracted from
|
|
@@ -1837,10 +1885,21 @@ function wrapHead(opts) {
|
|
|
1837
1885
|
// - apple → <link rel="apple-touch-icon">
|
|
1838
1886
|
// - shortcut→ <link rel="shortcut icon">
|
|
1839
1887
|
// - other → <link rel="…" href="…"> using the entry's `rel` field
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1888
|
+
//
|
|
1889
|
+
// With no `icons` declared, an `app/icon.*` / `app/apple-icon.*` metadata
|
|
1890
|
+
// ROUTE is linked automatically (Next parity). Those routes served their
|
|
1891
|
+
// bytes and nothing referenced them before, so writing the file that every
|
|
1892
|
+
// other framework treats as "this is my favicon" produced a blank tab and no
|
|
1893
|
+
// diagnostic. A declared `icons` SUPPRESSES the routes rather than merging
|
|
1894
|
+
// with them, which is also what Next does: it merges static icon files only
|
|
1895
|
+
// when the resolved metadata has no `icons` of its own. Suppressing matters
|
|
1896
|
+
// here because the file is frequently a placeholder an app has outgrown, and
|
|
1897
|
+
// an author who names their icons has said which ones they want.
|
|
1898
|
+
const declaredOrRouteIcons = m.icons || autoMetadataRouteIcons();
|
|
1899
|
+
if (declaredOrRouteIcons) {
|
|
1900
|
+
const buckets = typeof declaredOrRouteIcons === 'string' || Array.isArray(declaredOrRouteIcons)
|
|
1901
|
+
? { icon: declaredOrRouteIcons }
|
|
1902
|
+
: declaredOrRouteIcons;
|
|
1844
1903
|
/** @param {string} rel @param {unknown} entry */
|
|
1845
1904
|
const pushIcon = (rel, entry) => {
|
|
1846
1905
|
if (!entry) return;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time validation of the app's `webjs` package.json block (#1300).
|
|
3
|
+
*
|
|
4
|
+
* `webjs-config.schema.json` used to reach users through exactly one wire, the
|
|
5
|
+
* scaffold's `.vscode/settings.json` `$ref`, so a typo'd key was caught only for
|
|
6
|
+
* a VS Code user with `package.json` open. Everywhere else the key was silently
|
|
7
|
+
* dropped and the feature stayed at its default with no diagnostic, which is the
|
|
8
|
+
* exact gap the `WebjsConfig` type's own docblock claims to close.
|
|
9
|
+
*
|
|
10
|
+
* The validator now also runs at boot, from `createRequestHandler`, so `webjs
|
|
11
|
+
* dev`, `webjs start`, and an embedded host all get it from one call site.
|
|
12
|
+
*
|
|
13
|
+
* IT WARNS, NEVER THROWS. A typo costs one feature sitting at its default; a
|
|
14
|
+
* hard boot failure over a schema quibble costs the whole app, on a deploy, at
|
|
15
|
+
* the worst possible moment. That is also Next's posture (unknown and invalid
|
|
16
|
+
* options warn and the boot continues, only required or migrated options are
|
|
17
|
+
* fatal), and WebJs has no required keys at all since every one is optional with
|
|
18
|
+
* a default.
|
|
19
|
+
*
|
|
20
|
+
* This is deliberately SEPARATE from `readDoctorPolicy`'s hand-written
|
|
21
|
+
* `webjs.doctor.gate` validation in the CLI (#1257), which stays. That one runs
|
|
22
|
+
* where `@webjsdev/server` may not resolve (#954), checks gate keys against the
|
|
23
|
+
* real `DOCTOR_CODES` set (membership this schema cannot express), and fails
|
|
24
|
+
* CLOSED because a silently-ignored gate leaves CI un-gated while the
|
|
25
|
+
* package.json looks gated. One function cannot honour both failure modes, so
|
|
26
|
+
* do not "clean up" the apparent duplication.
|
|
27
|
+
*
|
|
28
|
+
* Depth stays at the top level on purpose: key membership, `enum`, and
|
|
29
|
+
* `boolean` / `integer` leaves. Descending into nested objects would have to
|
|
30
|
+
* reckon with the free-form `headers`, `redirects`, and `csp` shapes, which is a
|
|
31
|
+
* behaviour change of its own with real false-positive risk, so `webjs.dev.beforee`
|
|
32
|
+
* stays uncaught for now.
|
|
33
|
+
*/
|
|
34
|
+
import { readFileSync } from 'node:fs';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A tiny structural validator standing in for ajv (which the repo does not
|
|
39
|
+
* ship). It checks exactly three things and no others: key membership under
|
|
40
|
+
* `additionalProperties: false`, `enum` membership, and the `boolean` /
|
|
41
|
+
* `integer` leaf types.
|
|
42
|
+
*
|
|
43
|
+
* So a value is type-checked only when its schema declares `type: "boolean"` or
|
|
44
|
+
* `type: "integer"`, or an `enum`. Today that is 9 of the 17 top-level keys; the
|
|
45
|
+
* other 8 (`headers`, `redirects`, `allowedOrigins`, `basePath`, `csp`, `dev`,
|
|
46
|
+
* `start`, `doctor`) pass whatever they hold, `csp` because its schema is a
|
|
47
|
+
* `oneOf` with no `type` at all and the rest because theirs is `array`,
|
|
48
|
+
* `string`, or `object`. That is enough for the case this exists to close, a
|
|
49
|
+
* typo'd top-level key silently dropped, plus the leaf kinds a schema can decide
|
|
50
|
+
* unambiguously.
|
|
51
|
+
*
|
|
52
|
+
* Describe this by what it checks. Do not describe it by what a reader does
|
|
53
|
+
* with a value this passes over, which varies per reader and per branch within
|
|
54
|
+
* one reader and is wrong somewhere as soon as it is summarized.
|
|
55
|
+
*
|
|
56
|
+
* @param {Record<string, unknown>} schema the webjs-block schema
|
|
57
|
+
* @param {Record<string, unknown>} value a candidate `webjs` object
|
|
58
|
+
* @returns {string[]} a list of validation errors (empty = valid)
|
|
59
|
+
*/
|
|
60
|
+
export function validateWebjsBlock(schema, value) {
|
|
61
|
+
/** @type {string[]} */
|
|
62
|
+
const errors = [];
|
|
63
|
+
const props = schema.properties || {};
|
|
64
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
65
|
+
if (schema.additionalProperties === false && !(key in props)) {
|
|
66
|
+
errors.push(`unknown key "${key}"`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const def = /** @type {any} */ (props[key]);
|
|
70
|
+
if (!def) continue;
|
|
71
|
+
if (def.enum && !def.enum.includes(raw)) {
|
|
72
|
+
errors.push(`"${key}" must be one of ${JSON.stringify(def.enum)}`);
|
|
73
|
+
}
|
|
74
|
+
if (def.type === 'boolean' && typeof raw !== 'boolean') {
|
|
75
|
+
errors.push(`"${key}" must be a boolean`);
|
|
76
|
+
}
|
|
77
|
+
if (def.type === 'integer' && !Number.isInteger(raw)) {
|
|
78
|
+
errors.push(`"${key}" must be an integer`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return errors;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @type {Record<string, unknown> | null} */
|
|
85
|
+
let cachedSchema = null;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load the published `webjs-config.schema.json` that sits at this package's
|
|
89
|
+
* root. Memoized for the process lifetime, since it ships inside the tarball and
|
|
90
|
+
* cannot change under a running server. An unreadable or unparseable schema
|
|
91
|
+
* yields `null`, which makes validation a no-op rather than a boot failure.
|
|
92
|
+
*
|
|
93
|
+
* @returns {Record<string, unknown> | null}
|
|
94
|
+
*/
|
|
95
|
+
function loadSchema() {
|
|
96
|
+
if (cachedSchema) return cachedSchema;
|
|
97
|
+
try {
|
|
98
|
+
const path = fileURLToPath(new URL('../webjs-config.schema.json', import.meta.url));
|
|
99
|
+
cachedSchema = JSON.parse(readFileSync(path, 'utf8'));
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return cachedSchema;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate the `webjs` block of a parsed package.json against the published
|
|
108
|
+
* schema.
|
|
109
|
+
*
|
|
110
|
+
* Returns `[]` for an app with no `webjs` block and for one whose `webjs` is not
|
|
111
|
+
* a plain object, since neither is a typo this can usefully report and the
|
|
112
|
+
* readers already treat both as "unconfigured".
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} pkg a parsed package.json (or anything)
|
|
115
|
+
* @returns {string[]} one message per problem (empty = nothing to say)
|
|
116
|
+
*/
|
|
117
|
+
export function validateAppWebjsConfig(pkg) {
|
|
118
|
+
const block = /** @type {any} */ (pkg)?.webjs;
|
|
119
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) return [];
|
|
120
|
+
const schema = loadSchema();
|
|
121
|
+
if (!schema) return [];
|
|
122
|
+
return validateWebjsBlock(schema, block);
|
|
123
|
+
}
|