@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.60",
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.48",
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 - a browser-visible URL path like `/actions/foo.server.js`
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 - path relative to appDir
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
- // A hole binds an action when the literal segment IMMEDIATELY before it
1400
- // ends inside a start tag at the attribute that tag can really bind:
1401
- // `action=` on a `<form>` (#1155), or `formaction=` on a `<button>` /
1402
- // `<input>` submitter (#1207). Reading the segment rather than the whole
1403
- // template is what keeps `<div action=${x}>` out. The tag and the
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
- const before = literals[Number(m[1])] || '';
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;
@@ -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
- /** Client-only named APIs from the `@webjsdev/core` main entry. */
242
- const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate'];
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).
@@ -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. The submitter's enclosing <form> binds no action.`,
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
- const raw =
71
- pkg &&
72
- typeof pkg === 'object' &&
73
- /** @type {any} */ (pkg).webjs &&
74
- /** @type {any} */ (pkg).webjs.headers;
75
- if (!Array.isArray(raw)) return [];
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') continue;
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' || !Array.isArray(list)) continue;
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
- continue; // skip an invalid pattern rather than crash the request
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') continue;
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) continue;
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
- // eslint-disable-next-line no-console
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
- if (directives.length) rules.push({ pattern, directives });
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
- const raw =
235
- pkg &&
236
- typeof pkg === 'object' &&
237
- /** @type {any} */ (pkg).webjs &&
238
- /** @type {any} */ (pkg).webjs.redirects;
239
- if (!Array.isArray(raw)) return [];
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') continue;
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 entry (${reason}):`, entry);
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
- if (m.icons) {
1841
- const buckets = typeof m.icons === 'string' || Array.isArray(m.icons)
1842
- ? { icon: m.icons }
1843
- : m.icons;
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
+ }