@webjsdev/server 0.8.32 → 0.8.34

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 CHANGED
@@ -457,6 +457,21 @@ export declare function findOrphanComponents(
457
457
  appDir: string,
458
458
  ): Promise<Array<{ className: string; file: string }>>;
459
459
 
460
+ // ---------------------------------------------------------------------------
461
+ // elision-report.js (#646)
462
+ // ---------------------------------------------------------------------------
463
+
464
+ /**
465
+ * App-level elision report: the page/layout route modules that SHIP WHOLE to
466
+ * the browser instead of being elided as carriers, each with the first
467
+ * client-effecting blocker that pins it (or `null` when the module's own code
468
+ * is the cause) plus a human reason. A reporting layer over `analyzeElision`,
469
+ * consumed by the `webjs doctor` carrier-hygiene advisory (#646).
470
+ */
471
+ export declare function analyzeAppElision(
472
+ appDir: string,
473
+ ): Promise<{ analysed: boolean; shipped: Array<{ file: string; blocker: string | null; reason: string }> }>;
474
+
460
475
  // ---------------------------------------------------------------------------
461
476
  // context.js (per-request context helpers)
462
477
  // ---------------------------------------------------------------------------
package/index.js CHANGED
@@ -42,6 +42,7 @@ export {
42
42
  } from './src/vendor.js';
43
43
  export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
44
44
  export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
45
+ export { analyzeAppElision } from './src/elision-report.js';
45
46
  export { headers, cookies, getRequest, withRequest, cspNonce, requestId } from './src/context.js';
46
47
  export { defaultLogger } from './src/logger.js';
47
48
  export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
@@ -68,8 +69,6 @@ export { createAuth, Credentials, Google, GitHub } from './src/auth.js';
68
69
  export {
69
70
  testRequest,
70
71
  toRequest,
71
- getCsrf,
72
- readCsrfCookie,
73
72
  getSetCookies,
74
73
  cookiesToHeader,
75
74
  withCookies,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.32",
3
+ "version": "0.8.34",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/actions.js CHANGED
@@ -3,7 +3,7 @@ import { pathToFileURL } from 'node:url';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { join, sep } from 'node:path';
5
5
  import { walk } from './fs-walk.js';
6
- import { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
6
+ import { verifyOrigin } from './csrf.js';
7
7
  import { getSerializer } from './serializer.js';
8
8
  import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
9
9
  import {
@@ -311,7 +311,6 @@ export async function serveActionStub(idx, absFile) {
311
311
  */
312
312
  function buildStubBody({ hash, method, fnNames, actionUrl }) {
313
313
  const URL_ARG = URL_ARG_VERBS.has(method);
314
- const SAFE = SAFE_VERBS.has(method);
315
314
  const J = JSON.stringify;
316
315
  const lines = [];
317
316
  lines.push(`// webjs: generated server-action stub (${method})`);
@@ -334,7 +333,8 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
334
333
  lines.push(`const __METHOD = ${J(method)};`);
335
334
  lines.push(`const __MAX = ${MAX_URL_ARGS};`);
336
335
  lines.push(`const __CT = ${J(RPC_CONTENT_TYPE)};`);
337
- lines.push(`function __csrf() { const m = document.cookie.match(/(?:^|;\\s*)${CSRF_COOKIE}=([^;]+)/); return m ? decodeURIComponent(m[1]) : ''; }`);
336
+ // CSRF is enforced server-side by an Origin-header check, so the stub sends
337
+ // nothing extra: the browser attaches `Origin` to a same-origin POST itself.
338
338
  // Shared: parse a response, surface invalidation, register a GET's tags
339
339
  // (stamped with the clock SAMPLED BEFORE the fetch, so a mutation in flight is
340
340
  // caught on the next read).
@@ -383,8 +383,8 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
383
383
  // captured SYNCHRONOUSLY at __call entry (#492): the active render signal must
384
384
  // be read before the stub's first await (stringify), or the render's
385
385
  // synchronous window has already closed and cleared it.
386
- lines.push(`async function __body(fn, body, m, csrf, sig) {`);
387
- lines.push(` const headers = { 'content-type': __CT }; if (csrf) headers[${J(CSRF_HEADER)}] = __csrf();`);
386
+ lines.push(`async function __body(fn, body, m, sig) {`);
387
+ lines.push(` const headers = { 'content-type': __CT };`);
388
388
  lines.push(` const res = await fetch(__URL + fn, { method: m, headers, credentials: 'same-origin', body, signal: sig });`);
389
389
  lines.push(` return __handle(res, fn, null);`);
390
390
  lines.push(`}`);
@@ -394,14 +394,14 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
394
394
  lines.push(` const sig = __sig();`); // sync capture before any await
395
395
  lines.push(` const key = await __s(args);`);
396
396
  lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
397
- lines.push(` if (key.length > __MAX) return __body(fn, key, 'POST', ${SAFE ? 'false' : 'true'}, sig);`);
397
+ lines.push(` if (key.length > __MAX) return __body(fn, key, 'POST', sig);`);
398
398
  if (method === 'GET') {
399
399
  lines.push(` const bypass = __stale(key);`);
400
400
  lines.push(` const since = __mark();`);
401
401
  lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: 'GET', credentials: 'same-origin', cache: bypass ? 'no-cache' : 'default', signal: sig });`);
402
402
  lines.push(` return __handle(res, fn, key, since);`);
403
403
  } else {
404
- lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: __METHOD, headers: { ${J(CSRF_HEADER)}: __csrf() }, credentials: 'same-origin', signal: sig });`);
404
+ lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: __METHOD, credentials: 'same-origin', signal: sig });`);
405
405
  lines.push(` return __handle(res, fn, null);`);
406
406
  }
407
407
  lines.push(`}`);
@@ -413,7 +413,7 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
413
413
  lines.push(` const sig = __sig();`); // sync capture before any await
414
414
  lines.push(` const key = await __s(args);`);
415
415
  lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
416
- lines.push(` return __body(fn, key, __METHOD, true, sig);`);
416
+ lines.push(` return __body(fn, key, __METHOD, sig);`);
417
417
  lines.push(`}`);
418
418
  }
419
419
  for (const name of fnNames) {
@@ -436,8 +436,10 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
436
436
  * invoked when the action throws unexpectedly, BEFORE the sanitized 500 is
437
437
  * returned, so an APM integration sees the original error. The caller wraps
438
438
  * it so a throwing sink can never affect the response.
439
+ * @param {string[]} [allowedOrigins] cross-site origins the CSRF check allows
440
+ * (the app's `webjs.allowedOrigins`), for reverse-proxy / multi-domain setups.
439
441
  */
440
- export async function invokeAction(idx, hash, fnName, req, onError) {
442
+ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigins = []) {
441
443
  const file = idx.hashToFile.get(hash);
442
444
  if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
443
445
  const mod = await loadModule(file, idx.dev);
@@ -459,10 +461,14 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
459
461
  { status: 405, headers: { allow: [...allowed].join(', ') } },
460
462
  );
461
463
  }
462
- // CSRF: required for every verb except a safe GET (a read with no state
463
- // change, which is also browser-cacheable so it cannot carry a fresh token).
464
- if (!SAFE_VERBS.has(method) && !verifyCsrf(req)) {
465
- return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
464
+ // CSRF: an Origin-header check on every state-changing verb (a safe GET is a
465
+ // read with no state change). A browser sets `Origin` on a cross-site POST to
466
+ // the ATTACKER's origin, so a forged request fails the host match. No token
467
+ // or cookie is involved, which keeps SSR HTML free of `Set-Cookie` and so
468
+ // CDN-cacheable. See csrf.js.
469
+ if (!SAFE_VERBS.has(method)) {
470
+ const v = verifyOrigin(req, allowedOrigins);
471
+ if (!v.ok) return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
466
472
  }
467
473
 
468
474
  // Args ride the URL for a URL-arg request (GET / DELETE), else the body.
package/src/check.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  redactStringsAndTemplates,
6
6
  extractWebComponentClassBodies,
7
7
  matchClosingBrace,
8
+ parsePropEntries,
8
9
  } from './js-scan.js';
9
10
  import { buildModuleGraph, transitiveDeps } from './module-graph.js';
10
11
  import { scanComponents } from './component-scanner.js';
@@ -83,6 +84,11 @@ export const RULES = [
83
84
  description:
84
85
  'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field initializer (`count = 0` or `count: number = 0`) in the class body. Under modern class-field semantics that initializer runs Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default via the `default` property option (`prop(Number, { default: 0 })`) or by assigning in the constructor after super().',
85
86
  },
87
+ {
88
+ name: 'array-prop-uses-array-type',
89
+ description:
90
+ 'An array-typed reactive property declared via the `extends WebComponent({ … })` factory must pass the `Array` runtime constructor, not `Object`: `count: prop<Tag[]>(Array)`, never `prop<Tag[]>(Object)`. The two share one converter (both JSON-encode the value), so the wrong one does not crash, but `Object` misstates the prop contract to the next reader and diverges from the documented built-in set (String/Number/Boolean/Object/Array). Fires only when the factory generic is itself an array type (`T[]`, `readonly T[]`, `Array<T>`, `ReadonlyArray<T>`) AND the constructor argument is `Object`; a bare `foo: Object` with no generic is never flagged. Fix: change the constructor to `Array`.',
91
+ },
86
92
  {
87
93
  name: 'shell-in-non-root-layout',
88
94
  description:
@@ -184,6 +190,38 @@ function isComponentFile(relPath) {
184
190
  * @param {Set<string>} props
185
191
  * @returns {string[]}
186
192
  */
193
+ /**
194
+ * True when a factory prop value is an array-typed `prop<…>(…)` whose
195
+ * runtime constructor argument is `Object`. The generic and the
196
+ * constructor sit in the same call, so it is decidable from the value
197
+ * text alone. The match is greedy on the generic so a nested generic
198
+ * (`prop<Array<X>>(Object)`) closes at the outer `>` that precedes the
199
+ * `(Object` call. A bare constructor (`Object`, with no generic) or a
200
+ * non-array generic (`prop<Foo>(Object)`) returns false.
201
+ *
202
+ * @param {string} value the raw prop value text, e.g. `prop<Tag[]>(Object)`
203
+ * @returns {boolean}
204
+ */
205
+ function arrayPropUsesObject(value) {
206
+ const m = /^prop\s*<([\s\S]*)>\s*\(\s*Object\s*[,)]/.exec(value.trim());
207
+ if (!m) return false;
208
+ return isArrayTypeText(m[1]);
209
+ }
210
+
211
+ /**
212
+ * True when a TypeScript type expression denotes an array: `T[]`,
213
+ * `readonly T[]`, `T[][]`, `Array<T>`, or `ReadonlyArray<T>`.
214
+ *
215
+ * @param {string} type
216
+ * @returns {boolean}
217
+ */
218
+ function isArrayTypeText(type) {
219
+ const bare = type.trim().replace(/^readonly\s+/, '');
220
+ if (/\[\s*\]$/.test(bare)) return true;
221
+ if (/^(?:Readonly)?Array\s*<[\s\S]*>$/.test(bare)) return true;
222
+ return false;
223
+ }
224
+
187
225
  function findFieldInitializers(classBody, props) {
188
226
  /** @type {string[]} */
189
227
  const out = [];
@@ -440,6 +478,37 @@ export async function checkConventions(appDir) {
440
478
  }
441
479
  }
442
480
 
481
+ // --- Rule: array-prop-uses-array-type ---
482
+ // An array-typed reactive prop declared via the factory should pass the
483
+ // `Array` runtime constructor, not `Object`. Both share one converter
484
+ // (JSON encode/decode), so `Object` does not crash, but it misstates the
485
+ // prop contract and diverges from the documented built-in set. Fires only
486
+ // when the factory generic is itself an array type AND the constructor is
487
+ // `Object`; a bare `foo: Object` (no generic to prove array-ness) is left
488
+ // alone to avoid false positives. Uses the redacted `scan`, so a
489
+ // `prop<X[]>(Object)` shown inside an html`` example string never fires.
490
+ {
491
+ for (const { rel, scan } of files) {
492
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
493
+ for (const { factoryArg } of extractWebComponentClassBodies(scan)) {
494
+ const objStart = factoryArg.indexOf('{');
495
+ if (objStart === -1) continue;
496
+ const objEnd = matchClosingBrace(factoryArg, objStart + 1);
497
+ if (objEnd === -1) continue;
498
+ const objContent = factoryArg.slice(objStart + 1, objEnd);
499
+ for (const { key, value } of parsePropEntries(objContent)) {
500
+ if (!arrayPropUsesObject(value)) continue;
501
+ violations.push({
502
+ rule: 'array-prop-uses-array-type',
503
+ file: rel,
504
+ message: `Array-typed reactive prop \`${key}\` is declared with the \`Object\` constructor (\`prop<…[]>(Object)\`); use \`Array\` so the runtime converter matches the declared shape.`,
505
+ fix: `Change the constructor to \`Array\`: \`${key}: prop<…[]>(Array)\`. Object and Array share one converter so behaviour is unchanged, but \`Array\` states the prop's shape correctly.`,
506
+ });
507
+ }
508
+ }
509
+ }
510
+ }
511
+
443
512
  // --- Rule: no-browser-globals-in-render ---
444
513
  // The SSR pipeline runs the constructor (`new Cls()`), willUpdate, and
445
514
  // render() on the server element shim (attribute methods backed, but no real