@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/src/js-scan.js
CHANGED
|
@@ -81,9 +81,38 @@ const REGEX_PRECEDING_KEYWORDS = new Set([
|
|
|
81
81
|
* @returns {string}
|
|
82
82
|
*/
|
|
83
83
|
export function redactStringsAndTemplates(src, blankStrings = false) {
|
|
84
|
+
return scanLiterals(src, { placeholder: false, blankStrings }).out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Core string / template / comment lexer shared by `redactStringsAndTemplates`
|
|
89
|
+
* (#179) and `redactToPlaceholders` (#634). ONE walker owns the
|
|
90
|
+
* regex-versus-division, tagged-template, and `${...}`-nesting disambiguation,
|
|
91
|
+
* so the two output modes can never drift apart.
|
|
92
|
+
*
|
|
93
|
+
* Two output modes, selected by `placeholder`:
|
|
94
|
+
* - MASK (placeholder=false): position-preserving mask (same length, newlines
|
|
95
|
+
* kept). Comment and regex bodies blank to spaces; template bodies blank,
|
|
96
|
+
* recursing `${...}` holes as blanked code; plain-string bodies stay VERBATIM
|
|
97
|
+
* so a caller can read `register('tag')`, UNLESS `blankStrings` is set (then
|
|
98
|
+
* string bodies blank too and the verbatim-template fast path is disabled).
|
|
99
|
+
* `literals` is empty.
|
|
100
|
+
* - PLACEHOLDER (placeholder=true): each string / template-text body is
|
|
101
|
+
* replaced by a `__STR_<idx>__` token (delimiters kept) and pushed to
|
|
102
|
+
* `literals`, while `${...}` holes are scanned as REAL code. Lets a caller
|
|
103
|
+
* tell a real top-level `register(...)` / `import` from one shown inside a
|
|
104
|
+
* code-sample literal (resolve the token via `literals[idx]`). In this mode
|
|
105
|
+
* code is never blanked, so the mask emit-formulas below reduce to verbatim.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} src
|
|
108
|
+
* @param {{ placeholder?: boolean, blankStrings?: boolean }} [opts]
|
|
109
|
+
* @returns {{ out: string, literals: string[] }}
|
|
110
|
+
*/
|
|
111
|
+
function scanLiterals(src, { placeholder = false, blankStrings = false } = {}) {
|
|
84
112
|
const n = src.length;
|
|
85
113
|
let out = '';
|
|
86
114
|
let i = 0;
|
|
115
|
+
const literals = [];
|
|
87
116
|
// Previous significant token in code position, tracked as we walk (more
|
|
88
117
|
// robust than scanning `out`, whose tail is blanked spaces inside a hole).
|
|
89
118
|
// `lastSig` is the last non-whitespace source char; `lastWord` is the last
|
|
@@ -112,7 +141,7 @@ export function redactStringsAndTemplates(src, blankStrings = false) {
|
|
|
112
141
|
if (/[\w$]/.test(lastSig)) return !lastWordIsProp && REGEX_PRECEDING_KEYWORDS.has(lastWord);
|
|
113
142
|
return true;
|
|
114
143
|
};
|
|
115
|
-
// A template is tagged when the previous token is a value.
|
|
144
|
+
// A template is tagged when the previous token is a value (mask mode only).
|
|
116
145
|
const isTagged = () => /[\w$)\]'"`]/.test(lastSig);
|
|
117
146
|
|
|
118
147
|
const scanLineComment = () => {
|
|
@@ -140,9 +169,25 @@ export function redactStringsAndTemplates(src, blankStrings = false) {
|
|
|
140
169
|
}
|
|
141
170
|
markValue();
|
|
142
171
|
};
|
|
143
|
-
// Strings:
|
|
172
|
+
// Strings. PLACEHOLDER: collect the body into `literals` and emit
|
|
173
|
+
// `q__STR_idx__q`. MASK: KEEP the body verbatim (so tag-name-has-hyphen can
|
|
144
174
|
// read register('foo')); blank it when inside an already-blanked hole.
|
|
145
175
|
const scanString = (q, blank) => {
|
|
176
|
+
if (placeholder) {
|
|
177
|
+
i++;
|
|
178
|
+
let body = '';
|
|
179
|
+
while (i < n) {
|
|
180
|
+
if (src[i] === '\\' && i + 1 < n) { body += src[i] + src[i + 1]; i += 2; continue; }
|
|
181
|
+
if (src[i] === q) { i++; break; }
|
|
182
|
+
if (src[i] === '\n') { i++; break; }
|
|
183
|
+
body += src[i]; i++;
|
|
184
|
+
}
|
|
185
|
+
const idx = literals.length;
|
|
186
|
+
literals.push(body);
|
|
187
|
+
out += q + `__STR_${idx}__` + q;
|
|
188
|
+
markValue();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
146
191
|
out += q; i++;
|
|
147
192
|
while (i < n) {
|
|
148
193
|
if (src[i] === '\\' && i + 1 < n) { out += blank ? ' ' : src[i] + src[i + 1]; i += 2; continue; }
|
|
@@ -152,9 +197,47 @@ export function redactStringsAndTemplates(src, blankStrings = false) {
|
|
|
152
197
|
}
|
|
153
198
|
markValue();
|
|
154
199
|
};
|
|
155
|
-
// Template literal.
|
|
156
|
-
// (
|
|
200
|
+
// Template literal. PLACEHOLDER: each text segment becomes a `__STR_idx__`
|
|
201
|
+
// token (collected) and each `${...}` hole is scanned as REAL code. MASK:
|
|
202
|
+
// verbatim fast path for a simple closed single-line untagged literal, else
|
|
203
|
+
// blank the text and recurse holes as blanked code (`forceBlank` is set when
|
|
204
|
+
// already inside a blanked hole, so everything nested blanks regardless).
|
|
157
205
|
const scanTemplate = (forceBlank) => {
|
|
206
|
+
if (placeholder) {
|
|
207
|
+
out += '`'; i++;
|
|
208
|
+
while (i < n) {
|
|
209
|
+
const c = src[i];
|
|
210
|
+
if (c === '\\' && i + 1 < n) {
|
|
211
|
+
let body = '\\' + src[i + 1];
|
|
212
|
+
i += 2;
|
|
213
|
+
while (i < n && src[i] !== '`' && !(src[i] === '$' && src[i + 1] === '{')) {
|
|
214
|
+
if (src[i] === '\\' && i + 1 < n) { body += src[i] + src[i + 1]; i += 2; continue; }
|
|
215
|
+
body += src[i]; i++;
|
|
216
|
+
}
|
|
217
|
+
const idx = literals.length;
|
|
218
|
+
literals.push(body);
|
|
219
|
+
out += `__STR_${idx}__`;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (c === '`') { out += '`'; i++; break; }
|
|
223
|
+
if (c === '$' && src[i + 1] === '{') {
|
|
224
|
+
out += '${'; i += 2;
|
|
225
|
+
scanCode(true, false);
|
|
226
|
+
if (i < n && src[i] === '}') { out += '}'; i++; }
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
let body = '';
|
|
230
|
+
while (i < n && src[i] !== '`' && !(src[i] === '$' && src[i + 1] === '{')) {
|
|
231
|
+
if (src[i] === '\\' && i + 1 < n) { body += src[i] + src[i + 1]; i += 2; continue; }
|
|
232
|
+
body += src[i]; i++;
|
|
233
|
+
}
|
|
234
|
+
const idx = literals.length;
|
|
235
|
+
literals.push(body);
|
|
236
|
+
out += `__STR_${idx}__`;
|
|
237
|
+
}
|
|
238
|
+
markValue();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
158
241
|
const tagged = isTagged();
|
|
159
242
|
let hasInterp = false, hasNewline = false, closed = false, depth = 0, k = i + 1;
|
|
160
243
|
while (k < n) {
|
|
@@ -198,7 +281,9 @@ export function redactStringsAndTemplates(src, blankStrings = false) {
|
|
|
198
281
|
|
|
199
282
|
// Scan code. `stopHole`: return at the `}` that closes the enclosing template
|
|
200
283
|
// hole (the caller emits it). `blank`: emit spaces for code (inside a blanked
|
|
201
|
-
// hole
|
|
284
|
+
// hole; mask mode only, never set in placeholder mode so the formulas below
|
|
285
|
+
// reduce to verbatim). Literals are always lexed so braces/quotes inside them
|
|
286
|
+
// never count.
|
|
202
287
|
function scanCode(stopHole, blank) {
|
|
203
288
|
let brace = 0;
|
|
204
289
|
while (i < n) {
|
|
@@ -228,7 +313,7 @@ export function redactStringsAndTemplates(src, blankStrings = false) {
|
|
|
228
313
|
}
|
|
229
314
|
|
|
230
315
|
scanCode(false, false);
|
|
231
|
-
return out;
|
|
316
|
+
return { out, literals };
|
|
232
317
|
}
|
|
233
318
|
|
|
234
319
|
/**
|
|
@@ -421,6 +506,73 @@ export function parsePropsFromObjectLiteral(obj, names) {
|
|
|
421
506
|
}
|
|
422
507
|
}
|
|
423
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Like {@link parsePropsFromObjectLiteral} but captures each entry's raw
|
|
511
|
+
* VALUE text alongside its key, so a rule can inspect the declaration
|
|
512
|
+
* (`prop<CommentFormatted[]>(Object)`) rather than just the name.
|
|
513
|
+
*
|
|
514
|
+
* The value is read with balanced depth across `()`, `{}`, `[]`, `<>`,
|
|
515
|
+
* and string/template literals, so an entry is split only on a
|
|
516
|
+
* TOP-LEVEL comma. That keeps a value's own commas intact, whether they
|
|
517
|
+
* sit inside an options object (`prop(Object, { default: () => [] })`),
|
|
518
|
+
* an array literal, or a nested generic (`prop<Map<string, number>>(…)`).
|
|
519
|
+
* Angle-bracket depth is decremented only when already open, so a stray
|
|
520
|
+
* `=>` arrow or `>=` does not unbalance a value with no generic.
|
|
521
|
+
*
|
|
522
|
+
* @param {string} obj object-literal body (no enclosing braces)
|
|
523
|
+
* @returns {{ key: string, value: string }[]}
|
|
524
|
+
*/
|
|
525
|
+
export function parsePropEntries(obj) {
|
|
526
|
+
/** @type {{ key: string, value: string }[]} */
|
|
527
|
+
const entries = [];
|
|
528
|
+
const n = obj.length;
|
|
529
|
+
let i = 0;
|
|
530
|
+
while (i < n) {
|
|
531
|
+
while (i < n && /[\s,]/.test(obj[i])) i++;
|
|
532
|
+
if (i >= n) break;
|
|
533
|
+
let key = '';
|
|
534
|
+
if (obj[i] === '"' || obj[i] === "'") {
|
|
535
|
+
const quote = obj[i++];
|
|
536
|
+
while (i < n && obj[i] !== quote) { key += obj[i++]; }
|
|
537
|
+
i++; // closing quote
|
|
538
|
+
} else {
|
|
539
|
+
while (i < n && /[A-Za-z0-9_$]/.test(obj[i])) key += obj[i++];
|
|
540
|
+
}
|
|
541
|
+
while (i < n && /\s/.test(obj[i])) i++;
|
|
542
|
+
if (obj[i] !== ':') break;
|
|
543
|
+
i++;
|
|
544
|
+
while (i < n && /\s/.test(obj[i])) i++;
|
|
545
|
+
let round = 0, curly = 0, square = 0, angle = 0;
|
|
546
|
+
let value = '';
|
|
547
|
+
while (i < n) {
|
|
548
|
+
const c = obj[i];
|
|
549
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
550
|
+
const quote = c;
|
|
551
|
+
value += c; i++;
|
|
552
|
+
while (i < n && obj[i] !== quote) {
|
|
553
|
+
if (obj[i] === '\\') { value += obj[i] + (obj[i + 1] || ''); i += 2; continue; }
|
|
554
|
+
value += obj[i++];
|
|
555
|
+
}
|
|
556
|
+
if (i < n) value += obj[i++];
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
if (c === ',' && round === 0 && curly === 0 && square === 0 && angle === 0) break;
|
|
560
|
+
if (c === '}' && curly === 0) break; // end of the enclosing object literal
|
|
561
|
+
if (c === '(') round++;
|
|
562
|
+
else if (c === ')') { if (round > 0) round--; }
|
|
563
|
+
else if (c === '{') curly++;
|
|
564
|
+
else if (c === '}') curly--;
|
|
565
|
+
else if (c === '[') square++;
|
|
566
|
+
else if (c === ']') { if (square > 0) square--; }
|
|
567
|
+
else if (c === '<') angle++;
|
|
568
|
+
else if (c === '>') { if (angle > 0) angle--; }
|
|
569
|
+
value += obj[i++];
|
|
570
|
+
}
|
|
571
|
+
if (key) entries.push({ key, value: value.trim() });
|
|
572
|
+
}
|
|
573
|
+
return entries;
|
|
574
|
+
}
|
|
575
|
+
|
|
424
576
|
|
|
425
577
|
/**
|
|
426
578
|
* Walk forward from `start` (just after an opening `(`) and return the
|
|
@@ -512,3 +664,22 @@ export function matchClosingBrace(s, start) {
|
|
|
512
664
|
}
|
|
513
665
|
return -1;
|
|
514
666
|
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Return `src` with the BODY of every comment replaced by spaces, and the
|
|
670
|
+
* body of single-quoted, double-quoted, and template-literal segments
|
|
671
|
+
* replaced by placeholders like `__STR_index__`. Quote delimiters and template
|
|
672
|
+
* backticks are preserved so code structure is unchanged. Template holes
|
|
673
|
+
* `${...}` are left intact (and scanned recursively as code).
|
|
674
|
+
*
|
|
675
|
+
* Returns `{ redacted: string, literals: string[] }` where `literals` holds
|
|
676
|
+
* the original unquoted literal contents.
|
|
677
|
+
*
|
|
678
|
+
* @param {string} src
|
|
679
|
+
* @returns {{ redacted: string, literals: string[] }}
|
|
680
|
+
*/
|
|
681
|
+
export function redactToPlaceholders(src) {
|
|
682
|
+
const { out, literals } = scanLiterals(src, { placeholder: true });
|
|
683
|
+
return { redacted: out, literals };
|
|
684
|
+
}
|
|
685
|
+
|
package/src/listener-bun.js
CHANGED
|
@@ -349,7 +349,7 @@ function maybeCompress(resp, req) {
|
|
|
349
349
|
* on Bun too.
|
|
350
350
|
* @param {{ requestTimeout?: number } | undefined} timeouts
|
|
351
351
|
*/
|
|
352
|
-
function bunIdleTimeout(timeouts) {
|
|
352
|
+
export function bunIdleTimeout(timeouts) {
|
|
353
353
|
const reqMs = timeouts && timeouts.requestTimeout;
|
|
354
354
|
if (reqMs === 0) return 0;
|
|
355
355
|
let secs = Math.ceil((reqMs || 30_000) / 1000);
|
package/src/ssr.js
CHANGED
|
@@ -5,7 +5,6 @@ import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPre
|
|
|
5
5
|
import { withBasePath } from './base-path.js';
|
|
6
6
|
import { withAssetHash } from './asset-hash.js';
|
|
7
7
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
8
|
-
import { readToken, newToken, cookieHeader } from './csrf.js';
|
|
9
8
|
import { transitiveDeps } from './module-graph.js';
|
|
10
9
|
import { seedingEnabled, collectSeeds, buildSeedScript } from './action-seed.js';
|
|
11
10
|
import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
|
|
@@ -148,10 +147,31 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
148
147
|
// zero application JS. The analysis is conservative (anything that
|
|
149
148
|
// touches the client router, a signal, an event, an npm import, or a
|
|
150
149
|
// shipping component keeps shipping).
|
|
150
|
+
//
|
|
151
|
+
// Import-only route modules (#605) go one step further: a page / layout
|
|
152
|
+
// whose only client relevance is importing shipping components is itself
|
|
153
|
+
// dead weight on the client (it never hydrates), so it is dropped and its
|
|
154
|
+
// component modules are emitted directly in its place. The component set is
|
|
155
|
+
// the STATIC closure the analyser computed (what loading the module would
|
|
156
|
+
// have registered), so a component imported but only conditionally rendered
|
|
157
|
+
// still registers. Dedup so a component shared across the page and a layout
|
|
158
|
+
// (or two layouts) is emitted once.
|
|
151
159
|
const inert = opts.inertRouteModules;
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
const importOnly = opts.importOnlyRouteModules;
|
|
161
|
+
const moduleUrls = [];
|
|
162
|
+
{
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
const push = (abs) => {
|
|
165
|
+
const u = toUrlPath(abs, opts.appDir);
|
|
166
|
+
if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
|
|
167
|
+
};
|
|
168
|
+
for (const f of [route.file, ...route.layouts]) {
|
|
169
|
+
if (inert && inert.has(f)) continue;
|
|
170
|
+
const emit = importOnly && importOnly.get(f);
|
|
171
|
+
if (emit) emit.forEach(push);
|
|
172
|
+
else push(f);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
155
175
|
// Emit <link rel="modulepreload"> for every custom element that
|
|
156
176
|
// actually rendered PLUS their transitive dependencies (from the
|
|
157
177
|
// module graph). URLs are deduplicated so the browser never sees
|
|
@@ -295,7 +315,8 @@ export async function ssrNotFound(notFoundFile, opts) {
|
|
|
295
315
|
}
|
|
296
316
|
|
|
297
317
|
/**
|
|
298
|
-
* Build an HTML Response
|
|
318
|
+
* Build an HTML Response. Sets no cookie: action CSRF is an Origin /
|
|
319
|
+
* Sec-Fetch-Site check, so the page response is cookieless (CDN-cacheable).
|
|
299
320
|
* @param {string} html
|
|
300
321
|
* @param {number} status
|
|
301
322
|
* @param {Request | undefined} req
|
|
@@ -314,10 +335,6 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
314
335
|
// final, so a warming response is reload-safe. See router-client.js
|
|
315
336
|
// applySwap and publishedBuildId() in importmap.js.
|
|
316
337
|
headers.set('x-webjs-build', publishedBuildId());
|
|
317
|
-
if (req && !readToken(req)) {
|
|
318
|
-
const secure = url ? url.protocol === 'https:' : false;
|
|
319
|
-
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
320
|
-
}
|
|
321
338
|
// Buffered (string) body: opt into the conditional-GET funnel so a
|
|
322
339
|
// PUBLIC-cacheable page (metadata.cacheControl) gets a weak ETag + 304.
|
|
323
340
|
// The funnel still excludes the no-store default, so a private page is
|
|
@@ -329,10 +346,10 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
329
346
|
/**
|
|
330
347
|
* Rebuild a Response from a cached HTML record (#241). The stored body is
|
|
331
348
|
* the stable per-page HTML; the per-response varying bits are re-minted
|
|
332
|
-
* here so a new visitor still gets them: the
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
349
|
+
* here so a new visitor still gets them: the published build id is re-read so
|
|
350
|
+
* a post-deploy client sees the current id. No cookie is set (action CSRF is
|
|
351
|
+
* an Origin / Sec-Fetch-Site check), which is what keeps a cached page
|
|
352
|
+
* cookieless and shareable. The BUFFERED marker opts the cached body into
|
|
336
353
|
* the conditional-GET funnel exactly as a fresh render does, so a cached
|
|
337
354
|
* PUBLIC-cacheable page still 304s. Output is observably identical to the
|
|
338
355
|
* fresh render of the same route within the window.
|
|
@@ -345,10 +362,6 @@ function cachedHtmlResponse(rec, req, url) {
|
|
|
345
362
|
const headers = new Headers({ 'content-type': rec.contentType });
|
|
346
363
|
headers.set('cache-control', rec.cacheControl);
|
|
347
364
|
headers.set('x-webjs-build', publishedBuildId());
|
|
348
|
-
if (req && !readToken(req)) {
|
|
349
|
-
const secure = url ? url.protocol === 'https:' : false;
|
|
350
|
-
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
351
|
-
}
|
|
352
365
|
headers.set(BUFFERED_MARKER, '1');
|
|
353
366
|
return new Response(rec.body, { status: rec.status, headers });
|
|
354
367
|
}
|
|
@@ -847,6 +860,19 @@ export function publicEnvShim(opts) {
|
|
|
847
860
|
+ `</script>`;
|
|
848
861
|
}
|
|
849
862
|
|
|
863
|
+
// Client-router opt-out (#629). Default ON (the framework's automatic-nav
|
|
864
|
+
// thesis): the router auto-enables in the browser when `@webjsdev/core` loads.
|
|
865
|
+
// `webjs.clientRouter: false` flips this off app-wide; `dev.js` reads the
|
|
866
|
+
// config at boot / each rebuild and calls `setClientRouterEnabled`, and
|
|
867
|
+
// `wrapHead` then emits a `window.__WEBJS_CLIENT_ROUTER__=false` flag BEFORE
|
|
868
|
+
// the deferred boot module so the bundle's module-end auto-enable skips. A
|
|
869
|
+
// module-level switch (mirrors setBasePath / setElisionFingerprint) so no opt
|
|
870
|
+
// has to thread through every render path; default true keeps every existing
|
|
871
|
+
// app and test byte-identical.
|
|
872
|
+
let _clientRouterEnabled = true;
|
|
873
|
+
export function setClientRouterEnabled(enabled) { _clientRouterEnabled = enabled !== false; }
|
|
874
|
+
function clientRouterEnabled() { return _clientRouterEnabled; }
|
|
875
|
+
|
|
850
876
|
function wrapHead(opts) {
|
|
851
877
|
// CSP nonce: if provided, all inline <script> tags get nonce="…" so they
|
|
852
878
|
// pass strict Content-Security-Policy headers. The nonce is extracted from
|
|
@@ -1355,7 +1381,7 @@ function wrapHead(opts) {
|
|
|
1355
1381
|
${opts.nonce ? `<meta name="csp-nonce" content="${escapeAttr(opts.nonce)}">` : ''}
|
|
1356
1382
|
${metaTags.join('\n')}
|
|
1357
1383
|
<title>${escapeHtml(title)}</title>
|
|
1358
|
-
${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}
|
|
1384
|
+
${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}${clientRouterEnabled() ? '' : `\n<script${n}>window.__WEBJS_CLIENT_ROUTER__=false;</script>`}
|
|
1359
1385
|
${importMapTag({ nonce: opts.nonce })}
|
|
1360
1386
|
${linkTags.join('\n')}
|
|
1361
1387
|
${scriptTags.length ? scriptTags.join('\n') + '\n' : ''}${boot}
|
|
@@ -1491,10 +1517,6 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1491
1517
|
// See htmlResponse: published build id on every response for the
|
|
1492
1518
|
// client router's importmap-mismatch detection on partial swaps.
|
|
1493
1519
|
headers.set('x-webjs-build', publishedBuildId());
|
|
1494
|
-
if (req && !readToken(req)) {
|
|
1495
|
-
const secure = url ? url.protocol === 'https:' : false;
|
|
1496
|
-
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
1497
|
-
}
|
|
1498
1520
|
|
|
1499
1521
|
if (!ctx.pending.length) {
|
|
1500
1522
|
// No pending boundaries: this degrades to a single buffered (string)
|
package/src/testing.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Type overlay for `@webjsdev/server/testing` (the handle() test harness, #267).
|
|
3
3
|
*
|
|
4
4
|
* The runtime is packages/server/src/testing.js (JSDoc-annotated JavaScript);
|
|
5
|
-
* this overlay exists so a TypeScript app's `import { testRequest,
|
|
6
|
-
* from '@webjsdev/server/testing'` resolves precise types instead of emitting
|
|
5
|
+
* this overlay exists so a TypeScript app's `import { testRequest,
|
|
6
|
+
* invokeActionForTest } from '@webjsdev/server/testing'` resolves precise types instead of emitting
|
|
7
7
|
* TS7016. The same declarations are re-exported from the package's main
|
|
8
8
|
* `index.d.ts` (the helpers ship from both entry points). Zero runtime cost.
|
|
9
9
|
*/
|
|
@@ -14,16 +14,6 @@ export type Handle = (req: Request) => Promise<Response> | Response;
|
|
|
14
14
|
/** A `createRequestHandler` return value, or a bare `handle` function. */
|
|
15
15
|
export type AppOrHandle = { handle: Handle; appDir: string } | Handle;
|
|
16
16
|
|
|
17
|
-
/** A CSRF token + cookie + header-name triple minted off the first SSR response. */
|
|
18
|
-
export interface CsrfPair {
|
|
19
|
-
/** The bare token value for the `x-webjs-csrf` request header. */
|
|
20
|
-
token: string;
|
|
21
|
-
/** The header NAME (`x-webjs-csrf`). */
|
|
22
|
-
header: string;
|
|
23
|
-
/** The `name=value` cookie string for the `Cookie` request header. */
|
|
24
|
-
cookie: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
17
|
/**
|
|
28
18
|
* Coerce a bare path, full URL string, or pre-built `Request` into a `Request`.
|
|
29
19
|
* A bare path is prefixed with a dummy origin; `init` is merged when provided.
|
|
@@ -40,21 +30,12 @@ export declare function testRequest(
|
|
|
40
30
|
init?: RequestInit,
|
|
41
31
|
): Promise<Response>;
|
|
42
32
|
|
|
43
|
-
/** Read the `webjs_csrf` cookie value off a response's `Set-Cookie` header(s). */
|
|
44
|
-
export declare function readCsrfCookie(res: Response): string | null;
|
|
45
|
-
|
|
46
33
|
/** Collect all `Set-Cookie` header values off a response. */
|
|
47
34
|
export declare function getSetCookies(res: Response): string[];
|
|
48
35
|
|
|
49
36
|
/** Reduce raw `Set-Cookie` strings to a single `name=value; name2=value2` `Cookie` header value. */
|
|
50
37
|
export declare function cookiesToHeader(setCookies: string[]): string;
|
|
51
38
|
|
|
52
|
-
/**
|
|
53
|
-
* Mint a `{ token, header, cookie }` CSRF triple off the first SSR response.
|
|
54
|
-
* `path` is the GET path used to issue the cookie (default `/`).
|
|
55
|
-
*/
|
|
56
|
-
export declare function getCsrf(handle: Handle, path?: string): Promise<CsrfPair>;
|
|
57
|
-
|
|
58
39
|
/** Merge an extra cookie string into a `RequestInit`'s `Cookie` header. */
|
|
59
40
|
export declare function withCookies(init: RequestInit | undefined, cookieValue: string): RequestInit;
|
|
60
41
|
|
|
@@ -89,7 +70,8 @@ export declare function actionEndpoint(
|
|
|
89
70
|
|
|
90
71
|
/**
|
|
91
72
|
* Round-trip an action through its REAL RPC endpoint (serializer + CSRF + prod
|
|
92
|
-
* error sanitization) and return the parsed return value.
|
|
73
|
+
* error sanitization) and return the parsed return value. Models a same-origin
|
|
74
|
+
* browser POST, so it passes the Origin / Sec-Fetch-Site CSRF check.
|
|
93
75
|
*/
|
|
94
76
|
export declare function invokeActionForTest<T = unknown>(
|
|
95
77
|
app: AppOrHandle,
|
|
@@ -97,7 +79,6 @@ export declare function invokeActionForTest<T = unknown>(
|
|
|
97
79
|
fnName: string,
|
|
98
80
|
args?: unknown[],
|
|
99
81
|
opts?: {
|
|
100
|
-
csrf?: CsrfPair;
|
|
101
82
|
appDir?: string;
|
|
102
83
|
extraCookies?: string;
|
|
103
84
|
throwOnError?: boolean;
|
|
@@ -106,7 +87,9 @@ export declare function invokeActionForTest<T = unknown>(
|
|
|
106
87
|
|
|
107
88
|
/**
|
|
108
89
|
* Lower-level variant of `invokeActionForTest` that returns the raw `Response`
|
|
109
|
-
* (never throws on a non-2xx), so a test can assert the status directly.
|
|
90
|
+
* (never throws on a non-2xx), so a test can assert the status directly. Set
|
|
91
|
+
* `crossOrigin` (true or a specific origin) to model a cross-site request and
|
|
92
|
+
* assert the 403.
|
|
110
93
|
*/
|
|
111
94
|
export declare function rawActionRequest(
|
|
112
95
|
app: AppOrHandle,
|
|
@@ -114,8 +97,7 @@ export declare function rawActionRequest(
|
|
|
114
97
|
fnName: string,
|
|
115
98
|
args?: unknown[],
|
|
116
99
|
opts?: {
|
|
117
|
-
|
|
118
|
-
omitCsrf?: boolean;
|
|
100
|
+
crossOrigin?: boolean | string;
|
|
119
101
|
appDir?: string;
|
|
120
102
|
extraCookies?: string;
|
|
121
103
|
contentType?: string;
|
package/src/testing.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* ```js
|
|
20
20
|
* import { createRequestHandler } from '@webjsdev/server';
|
|
21
|
-
* import { testRequest,
|
|
21
|
+
* import { testRequest, invokeActionForTest } from '@webjsdev/server/testing';
|
|
22
22
|
*
|
|
23
23
|
* const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
|
|
24
24
|
*
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { hashFile, RPC_CONTENT_TYPE } from './actions.js';
|
|
36
|
-
import { CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
|
|
37
36
|
import { getSerializer } from './serializer.js';
|
|
38
37
|
import { join, sep } from 'node:path';
|
|
39
38
|
|
|
@@ -83,24 +82,6 @@ export function testRequest(handle, input, init) {
|
|
|
83
82
|
return handle(toRequest(input, init));
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
/**
|
|
87
|
-
* Read the `webjs_csrf` cookie value off a response's `Set-Cookie` header(s).
|
|
88
|
-
* Returns null when the response set no CSRF cookie.
|
|
89
|
-
*
|
|
90
|
-
* @param {Response} res
|
|
91
|
-
* @returns {string | null}
|
|
92
|
-
*/
|
|
93
|
-
export function readCsrfCookie(res) {
|
|
94
|
-
const cookies = getSetCookies(res);
|
|
95
|
-
for (const c of cookies) {
|
|
96
|
-
const m = new RegExp(`(?:^|\\s)${CSRF_COOKIE}=([^;]+)`).exec(c);
|
|
97
|
-
if (m) {
|
|
98
|
-
try { return decodeURIComponent(m[1]); } catch { return m[1]; }
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
85
|
/**
|
|
105
86
|
* Collect every `Set-Cookie` value off a `Response`, preferring the
|
|
106
87
|
* structured `getSetCookie()` (which keeps multiple cookies separate) and
|
|
@@ -134,32 +115,6 @@ export function cookiesToHeader(setCookies) {
|
|
|
134
115
|
.join('; ');
|
|
135
116
|
}
|
|
136
117
|
|
|
137
|
-
/**
|
|
138
|
-
* Do an initial GET to mint a CSRF token, returning the `{ cookie, token }`
|
|
139
|
-
* pair so a test can send a CSRF-valid action request. `cookie` is a ready-made
|
|
140
|
-
* `Cookie` request-header value; `token` is the bare value for the
|
|
141
|
-
* `x-webjs-csrf` header. Reuses the REAL cookie / header names from `csrf.js`.
|
|
142
|
-
*
|
|
143
|
-
* @param {Handle} handle
|
|
144
|
-
* @param {string} [path] the GET path used to issue the cookie (default `/`)
|
|
145
|
-
* @returns {Promise<{ cookie: string, token: string, header: string }>}
|
|
146
|
-
*/
|
|
147
|
-
export async function getCsrf(handle, path = '/') {
|
|
148
|
-
const res = await testRequest(handle, path);
|
|
149
|
-
const token = readCsrfCookie(res);
|
|
150
|
-
if (!token) {
|
|
151
|
-
throw new Error(
|
|
152
|
-
`getCsrf: GET ${path} issued no ${CSRF_COOKIE} cookie. The first SSR ` +
|
|
153
|
-
`response issues it; hit a real page route (or pass a path that renders).`,
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
return {
|
|
157
|
-
token,
|
|
158
|
-
header: CSRF_HEADER,
|
|
159
|
-
cookie: `${CSRF_COOKIE}=${encodeURIComponent(token)}`,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
118
|
/**
|
|
164
119
|
* Merge an extra cookie string into a `RequestInit`'s `Cookie` header,
|
|
165
120
|
* preserving any cookie already on `init.headers`.
|
|
@@ -301,7 +256,6 @@ function isAbsolutePath(p) {
|
|
|
301
256
|
* @param {string} fnName the exported action function name
|
|
302
257
|
* @param {unknown[]} [args] positional args (serialized as the stub sends an arg array)
|
|
303
258
|
* @param {{
|
|
304
|
-
* csrf?: { cookie: string, token: string },
|
|
305
259
|
* appDir?: string,
|
|
306
260
|
* extraCookies?: string,
|
|
307
261
|
* throwOnError?: boolean,
|
|
@@ -320,23 +274,17 @@ export async function invokeActionForTest(app, serverFilePath, fnName, args = []
|
|
|
320
274
|
const throwOnError = opts.throwOnError !== false;
|
|
321
275
|
|
|
322
276
|
const endpoint = await actionEndpoint(appDir, serverFilePath, fnName);
|
|
323
|
-
const csrf = opts.csrf || (await getCsrf(handle));
|
|
324
277
|
|
|
325
278
|
const serializer = getSerializer();
|
|
326
279
|
const body = await serializer.serialize(args);
|
|
327
280
|
|
|
328
|
-
|
|
329
|
-
|
|
281
|
+
// Model a same-origin browser POST (the action CSRF check reads
|
|
282
|
+
// `Sec-Fetch-Site`, no token / cookie needed).
|
|
283
|
+
/** @type {Record<string,string>} */
|
|
284
|
+
const headers = { 'content-type': RPC_CONTENT_TYPE, 'sec-fetch-site': 'same-origin' };
|
|
285
|
+
if (opts.extraCookies) headers.cookie = opts.extraCookies;
|
|
330
286
|
|
|
331
|
-
const res = await testRequest(handle, endpoint, {
|
|
332
|
-
method: 'POST',
|
|
333
|
-
headers: {
|
|
334
|
-
'content-type': RPC_CONTENT_TYPE,
|
|
335
|
-
[CSRF_HEADER]: csrf.token,
|
|
336
|
-
cookie,
|
|
337
|
-
},
|
|
338
|
-
body,
|
|
339
|
-
});
|
|
287
|
+
const res = await testRequest(handle, endpoint, { method: 'POST', headers, body });
|
|
340
288
|
|
|
341
289
|
const text = await res.text();
|
|
342
290
|
const ct = res.headers.get('content-type') || '';
|
|
@@ -357,17 +305,17 @@ export async function invokeActionForTest(app, serverFilePath, fnName, args = []
|
|
|
357
305
|
|
|
358
306
|
/**
|
|
359
307
|
* Lower-level variant of `invokeActionForTest` that returns the raw `Response`
|
|
360
|
-
* (never throws on a non-2xx), so a test can assert on the status directly
|
|
361
|
-
*
|
|
362
|
-
* `opts.
|
|
308
|
+
* (never throws on a non-2xx), so a test can assert on the status directly.
|
|
309
|
+
* By default it models a same-origin browser POST (passes the CSRF check); set
|
|
310
|
+
* `opts.crossOrigin` (true, or a specific origin string) to model a cross-site
|
|
311
|
+
* request and assert the 403.
|
|
363
312
|
*
|
|
364
313
|
* @param {{ handle: Handle, appDir: string } | Handle} app
|
|
365
314
|
* @param {string} serverFilePath
|
|
366
315
|
* @param {string} fnName
|
|
367
316
|
* @param {unknown[]} [args]
|
|
368
317
|
* @param {{
|
|
369
|
-
*
|
|
370
|
-
* omitCsrf?: boolean,
|
|
318
|
+
* crossOrigin?: boolean | string,
|
|
371
319
|
* appDir?: string,
|
|
372
320
|
* extraCookies?: string,
|
|
373
321
|
* contentType?: string,
|
|
@@ -386,15 +334,13 @@ export async function rawActionRequest(app, serverFilePath, fnName, args = [], o
|
|
|
386
334
|
|
|
387
335
|
/** @type {Record<string,string>} */
|
|
388
336
|
const headers = { 'content-type': opts.contentType || RPC_CONTENT_TYPE };
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
headers
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
headers.cookie = cookie;
|
|
395
|
-
} else if (opts.extraCookies) {
|
|
396
|
-
headers.cookie = opts.extraCookies;
|
|
337
|
+
if (opts.crossOrigin) {
|
|
338
|
+
headers['sec-fetch-site'] = 'cross-site';
|
|
339
|
+
headers.origin = typeof opts.crossOrigin === 'string' ? opts.crossOrigin : 'https://evil.example';
|
|
340
|
+
} else {
|
|
341
|
+
headers['sec-fetch-site'] = 'same-origin';
|
|
397
342
|
}
|
|
343
|
+
if (opts.extraCookies) headers.cookie = opts.extraCookies;
|
|
398
344
|
|
|
399
345
|
return testRequest(handle, endpoint, { method: 'POST', headers, body });
|
|
400
346
|
}
|
package/src/vendor.js
CHANGED
|
@@ -100,12 +100,15 @@ export async function scanBareImports(dir, skipFiles) {
|
|
|
100
100
|
* `'@tanstack/query'` → `'@tanstack/query'`
|
|
101
101
|
* `'@tanstack/query/x'` → `'@tanstack/query'`
|
|
102
102
|
* `'./foo'`, `'../bar'`, `'/baz'` → `null` (relative/absolute)
|
|
103
|
+
* `'#components/x.ts'`, `'#lib/db'` → `null` (`#` path alias, #555: resolves to
|
|
104
|
+
* a LOCAL file via `package.json` "imports", never an npm package, so it must
|
|
105
|
+
* not be sent to the vendor resolver, #623)
|
|
103
106
|
*
|
|
104
107
|
* @param {string} spec
|
|
105
108
|
* @returns {string | null}
|
|
106
109
|
*/
|
|
107
110
|
export function extractPackageName(spec) {
|
|
108
|
-
if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('__')) return null;
|
|
111
|
+
if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('__') || spec.startsWith('#')) return null;
|
|
109
112
|
if (/^[a-z]+:/.test(spec)) return null;
|
|
110
113
|
if (spec.startsWith('@')) {
|
|
111
114
|
const parts = spec.split('/');
|
package/webjs-config.schema.json
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
"type": "boolean",
|
|
18
18
|
"default": true
|
|
19
19
|
},
|
|
20
|
+
"clientRouter": {
|
|
21
|
+
"description": "Client-router switch (#629). Default true: the client router auto-enables in the browser whenever @webjsdev/core loads, so any page with a component gets SPA-style navigation with no import. Set to false to opt the whole app out (pure MPA, full-page navigation). Read by readClientRouterEnabled in dev.js.",
|
|
22
|
+
"type": "boolean",
|
|
23
|
+
"default": true
|
|
24
|
+
},
|
|
20
25
|
"headers": {
|
|
21
26
|
"description": "Per-path response-header rules, shaped like Next's. Each rule pairs a URLPattern source against header directives. Read by compileHeaderRules in headers.js.",
|
|
22
27
|
"type": "array",
|
|
@@ -91,6 +96,12 @@
|
|
|
91
96
|
"type": "string",
|
|
92
97
|
"default": ""
|
|
93
98
|
},
|
|
99
|
+
"allowedOrigins": {
|
|
100
|
+
"description": "Cross-origin hosts (or full origins) the action CSRF check accepts even when the request is cross-site. The action endpoint defends with a Sec-Fetch-Site check plus an Origin-vs-host fallback; list additional origins a reverse-proxy / multi-domain setup must allow here. A bare host or a full origin are both accepted. Read by readAllowedOrigins in csrf.js. Default [] (same-origin only).",
|
|
101
|
+
"type": "array",
|
|
102
|
+
"items": { "type": "string" },
|
|
103
|
+
"default": []
|
|
104
|
+
},
|
|
94
105
|
"csp": {
|
|
95
106
|
"description": "Content-Security-Policy config. Off by default. true enables a strict nonce-based default policy. An object customizes directives and report-only mode. Read by readCspConfig in csp.js.",
|
|
96
107
|
"oneOf": [
|