@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/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: KEEP the body verbatim at top level (so tag-name-has-hyphen can
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. `forceBlank` is set when already inside a blanked hole
156
- // (everything nested blanks regardless of tag/shape).
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). Literals are always lexed so braces/quotes inside them never count.
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
+
@@ -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 moduleUrls = [route.file, ...route.layouts]
153
- .filter((f) => !(inert && inert.has(f)))
154
- .map((f) => toUrlPath(f, opts.appDir));
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 and, if missing, attach the CSRF cookie.
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 CSRF cookie is freshly issued
333
- * when the request lacks one (it is a Set-Cookie header, never part of the
334
- * cached body), and the published build id is re-read so a post-deploy
335
- * client sees the current id. The BUFFERED marker opts the cached body into
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, getCsrf }
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
- csrf?: CsrfPair | null;
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, getCsrf, invokeActionForTest } from '@webjsdev/server/testing';
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
- let cookie = csrf.cookie || `${CSRF_COOKIE}=${encodeURIComponent(csrf.token)}`;
329
- if (opts.extraCookies) cookie = `${cookie}; ${opts.extraCookies}`;
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
- * (e.g. a CSRF-missing request returns 403). Send `opts.csrf = null` /
362
- * `opts.omitCsrf = true` to deliberately omit the CSRF header + cookie.
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
- * csrf?: { cookie: string, token: string } | null,
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 (!opts.omitCsrf && opts.csrf !== null) {
390
- const csrf = opts.csrf || (await getCsrf(handle));
391
- headers[CSRF_HEADER] = csrf.token;
392
- let cookie = csrf.cookie || `${CSRF_COOKIE}=${encodeURIComponent(csrf.token)}`;
393
- if (opts.extraCookies) cookie = `${cookie}; ${opts.extraCookies}`;
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('/');
@@ -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": [