@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 +15 -0
- package/index.js +1 -2
- package/package.json +1 -1
- package/src/actions.js +19 -13
- package/src/check.js +69 -0
- package/src/component-elision.js +265 -64
- package/src/component-scanner.js +15 -10
- package/src/csrf.js +101 -61
- package/src/dev.js +81 -7
- package/src/elision-report.js +89 -0
- package/src/html-cache.js +5 -31
- package/src/js-scan.js +177 -6
- package/src/listener-bun.js +1 -1
- package/src/ssr.js +44 -22
- package/src/testing.d.ts +8 -26
- package/src/testing.js +18 -72
- package/src/vendor.js +4 -1
- package/webjs-config.schema.json +11 -0
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
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 {
|
|
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
|
-
|
|
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,
|
|
387
|
-
lines.push(` const headers = { 'content-type': __CT }
|
|
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',
|
|
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,
|
|
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,
|
|
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:
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
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
|