@webjsdev/server 0.8.12 → 0.8.13

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.js CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  listPinned,
28
28
  auditPinned,
29
29
  findOutdated,
30
+ hasVendorPin,
30
31
  updatePinned,
31
32
  readPinFile,
32
33
  serveDownloadedBundle,
@@ -46,8 +47,25 @@ export { revalidatePath, revalidateAll } from './src/html-cache.js';
46
47
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
47
48
  export { broadcast } from './src/broadcast.js';
48
49
  export { json, readBody } from './src/json.js';
50
+ export { sitemap, sitemapIndex } from './src/sitemap.js';
49
51
  export { attachWebSocket } from './src/websocket.js';
50
52
  export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
51
53
 
52
54
  // Auth (NextAuth-style)
53
55
  export { createAuth, Credentials, Google, GitHub } from './src/auth.js';
56
+
57
+ // Test harness helpers (issue #267): thin builders over handle()
58
+ export {
59
+ testRequest,
60
+ toRequest,
61
+ getCsrf,
62
+ readCsrfCookie,
63
+ getSetCookies,
64
+ cookiesToHeader,
65
+ withCookies,
66
+ withSessionCookie,
67
+ loginAndGetCookies,
68
+ actionEndpoint,
69
+ invokeActionForTest,
70
+ rawActionRequest,
71
+ } from './src/testing.js';
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
9
  "./check": "./src/check.js",
10
+ "./testing": "./src/testing.js",
10
11
  "./webjs-config.schema.json": "./webjs-config.schema.json"
11
12
  },
12
13
  "files": [
package/src/actions.js CHANGED
@@ -33,6 +33,60 @@ function jsonBodyLimit() {
33
33
  */
34
34
  export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
35
35
 
36
+ /**
37
+ * Run an attached input validator against an action's input, shared by the RPC
38
+ * path (`invokeAction`) and the `expose()` REST path (`invokeExposedAction`) so
39
+ * the contract is identical across transports (#245). The framework only CALLS
40
+ * the validator and shapes the result; it ships no validation library.
41
+ *
42
+ * @param {(input: any) => any} validate the attached validator
43
+ * @param {any} input the value handed to the validator (the action's first arg
44
+ * on RPC, the merged query/params/body object on REST)
45
+ * @returns {{ ok: true, value: any } | { ok: false, result: { success: false, fieldErrors?: Record<string,string>, error?: string, status: number }, thrown?: unknown }}
46
+ * `ok: true` carries the value to pass to the action (the validated `data`,
47
+ * the transformed return, or the original input). `ok: false` carries the
48
+ * structured failure result the caller serializes back; on a THROWN validator
49
+ * it also carries the original error on `thrown` so a caller can salvage a
50
+ * schema lib's structured `issues` (the REST path does, for back-compat).
51
+ */
52
+ export function runValidate(validate, input) {
53
+ let out;
54
+ try {
55
+ out = validate(input);
56
+ } catch (e) {
57
+ // Throw-to-reject (the classic `Schema.parse` contract). Map to a sanitized
58
+ // failure result; a thrown validator is a 400 (bad input), not a 500.
59
+ // Surface only the message; `thrown` lets the REST caller recover `issues`.
60
+ const msg = e instanceof Error ? e.message : String(e);
61
+ return { ok: false, result: { success: false, error: msg, status: 400 }, thrown: e };
62
+ }
63
+ // Disambiguate an ActionResult envelope from a plain transformed value: a
64
+ // return is an envelope ONLY when it is an object carrying a boolean
65
+ // `success` OR a `fieldErrors`. Anything else is a transformed input.
66
+ if (out && typeof out === 'object' && !Array.isArray(out) &&
67
+ (typeof (/** @type any */ (out).success) === 'boolean' || 'fieldErrors' in out)) {
68
+ const env = /** @type any */ (out);
69
+ if (env.success === true) {
70
+ // `{ success: true, data? }` → valid; use `data` when present, else input.
71
+ return { ok: true, value: 'data' in env ? env.data : input };
72
+ }
73
+ // Failure: `{ success: false, ... }` or `{ fieldErrors }` (no literal
74
+ // success). Shape the structured field-error result.
75
+ return {
76
+ ok: false,
77
+ result: {
78
+ success: false,
79
+ ...(env.fieldErrors ? { fieldErrors: env.fieldErrors } : {}),
80
+ ...(env.message || env.error ? { error: env.message || env.error } : {}),
81
+ status: typeof env.status === 'number' ? env.status : 422,
82
+ },
83
+ };
84
+ }
85
+ // A non-envelope return transforms the input (back-compat with
86
+ // `validate: Schema.parse`); `undefined` means "no transform", keep input.
87
+ return { ok: true, value: out === undefined ? input : out };
88
+ }
89
+
36
90
  /** Build a serialized Response with webjs content-type. */
37
91
  async function rpcResponse(payload, init = {}) {
38
92
  const s = getSerializer();
@@ -145,6 +199,12 @@ export async function buildActionIndex(appDir, dev) {
145
199
  if (typeof fn !== 'function') continue;
146
200
  const http = getExposed(fn);
147
201
  if (!http) continue;
202
+ // A `validateInput(fn, ...)` attachment writes `__webjsHttp` with a
203
+ // `validate` but NO `method`/`path` (it does not create a REST route),
204
+ // so it is not an exposed route. Only `expose()` sets a path; skip a
205
+ // validate-only attachment here (its validator is read at RPC call
206
+ // time via getExposed in invokeAction).
207
+ if (!http.path || !http.method) continue;
148
208
  const { pattern, paramNames } = pathToPattern(http.path);
149
209
  httpRoutes.push({
150
210
  method: http.method,
@@ -335,6 +395,30 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
335
395
  const mod = await loadModule(file, idx.dev);
336
396
  const fn = fnName === 'default' ? mod.default : mod[fnName];
337
397
  if (typeof fn !== 'function') return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
398
+ // Input validation (#245): a validator attached via `validateInput(fn, ...)`
399
+ // or `expose(spec, fn, { validate })` runs SERVER-SIDE before the body on
400
+ // this RPC path too, not just the REST path. The RPC stub sends an args
401
+ // array; an action conventionally takes one input object, so validate the
402
+ // FIRST arg (matching how the REST path validates its single merged object).
403
+ const attached = getExposed(fn);
404
+ if (attached && typeof attached.validate === 'function') {
405
+ const v = runValidate(attached.validate, args[0]);
406
+ if (!v.ok) {
407
+ if (v.thrown !== undefined) {
408
+ // A THROWN validator behaves like a thrown action: a sanitized error
409
+ // response (non-200, so the client stub throws), with prod stack
410
+ // sanitization. The onError sink sees the original error too.
411
+ if (typeof onError === 'function') onError(v.thrown);
412
+ return actionErrorResponse(v.thrown, idx.dev);
413
+ }
414
+ // A structured `{ success: false, fieldErrors }` envelope is a NORMAL
415
+ // result the action author renders (`result.fieldErrors`): serialized as
416
+ // a 200 RPC payload so the client stub returns it as a real object rather
417
+ // than throwing, the failure status riding inside the envelope.
418
+ return rpcResponse(v.result);
419
+ }
420
+ args = [v.value, ...args.slice(1)];
421
+ }
338
422
  try {
339
423
  const result = await fn(...args);
340
424
  return rpcResponse(result ?? null);
@@ -470,17 +554,26 @@ export async function invokeExposedAction(idx, route, params, req, onError) {
470
554
  }
471
555
  let arg = { ...query, ...params, ...body };
472
556
  if (route.validate) {
473
- try {
474
- arg = route.validate(arg);
475
- } catch (e) {
476
- const msg = e instanceof Error ? e.message : String(e);
477
- // Many schema libs (zod, valibot) throw structured errors: pass their
478
- // `issues` array through when present for easier client-side handling.
479
- const issues = e && typeof e === 'object' && 'issues' in e
480
- ? /** @type any */ (e).issues
481
- : undefined;
482
- return Response.json({ error: msg, issues }, { status: 400 });
557
+ // Run the validator through the SHARED contract (#245) so the REST path and
558
+ // the RPC path interpret a `{ success, fieldErrors }` envelope, a throw, and
559
+ // a transform-return identically. A structured failure becomes a 422 JSON
560
+ // `{ error?, fieldErrors }` (not just a thrown 400); a throw stays a 400; a
561
+ // transform-return keeps replacing the input (back-compat).
562
+ const v = runValidate(route.validate, arg);
563
+ if (!v.ok) {
564
+ if (v.thrown !== undefined) {
565
+ // A thrown validator (the classic `Schema.parse` style): keep the exact
566
+ // legacy REST shape, including a schema lib's structured `issues` array.
567
+ const msg = v.result.error || 'Invalid input';
568
+ const issues = v.thrown && typeof v.thrown === 'object' && 'issues' in v.thrown
569
+ ? /** @type any */ (v.thrown).issues
570
+ : undefined;
571
+ return Response.json({ error: msg, issues }, { status: 400 });
572
+ }
573
+ const { status, ...payload } = v.result;
574
+ return Response.json(payload, { status });
483
575
  }
576
+ arg = v.value;
484
577
  }
485
578
  const mod = await loadModule(route.file, idx.dev);
486
579
  const fn = mod[route.fnName];
package/src/sitemap.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Sitemap helpers (issue #276). Two pure functions that turn an array of
3
+ * entries into spec-valid XML, so an author of `app/sitemap.{js,ts}` does not
4
+ * hand-roll the `<urlset>` string (escaping URLs, formatting lastmod dates) and
5
+ * has a built-in path to shard a large site via a `<sitemapindex>`.
6
+ *
7
+ * Both are OPTIONAL and tiny. The dev server serves a STRING returned from a
8
+ * `sitemap.{js,ts}` default export as `application/xml`, so the author returns
9
+ * `sitemap([...])` from that function. They can still return a raw Response or a
10
+ * hand-built string, so this is thin XML serialization, not a sitemap framework.
11
+ *
12
+ * Reference: the sitemaps.org 0.9 protocol; Next.js `sitemap.ts` (a typed array
13
+ * serialized to XML) and `generateSitemaps()` (the index for sharding).
14
+ */
15
+
16
+ /** Per-URL entry for {@link sitemap}.
17
+ * @typedef {object} SitemapEntry
18
+ * @property {string} url Absolute URL (REQUIRED). XML-escaped on emit.
19
+ * @property {string|Date} [lastModified] Last-modified time, formatted as W3C
20
+ * datetime (ISO 8601). A Date is `toISOString()`'d; a string passes through.
21
+ * @property {'always'|'hourly'|'daily'|'weekly'|'monthly'|'yearly'|'never'} [changeFrequency]
22
+ * How often the page changes. An invalid value is dropped (not emitted).
23
+ * @property {number} [priority] Priority 0.0..1.0. Out-of-range / non-numeric
24
+ * is dropped.
25
+ */
26
+
27
+ /** Child-sitemap entry for {@link sitemapIndex}.
28
+ * @typedef {object} SitemapIndexEntry
29
+ * @property {string} url Absolute URL of a child sitemap (REQUIRED).
30
+ * @property {string|Date} [lastModified] Last-modified time (W3C datetime).
31
+ */
32
+
33
+ const VALID_CHANGEFREQ = new Set([
34
+ 'always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never',
35
+ ]);
36
+
37
+ const MAX_URLS = 50000;
38
+
39
+ /**
40
+ * XML-escape text destined for an element body. This is the security-critical
41
+ * part: a url derived from author data or a DB can contain `&` or `<`, which
42
+ * would break the XML document or allow injection if emitted raw. The five
43
+ * predefined XML entities are replaced (`&` first, so the entities the other
44
+ * replacements introduce are not double-escaped).
45
+ * @param {string} value
46
+ * @returns {string}
47
+ */
48
+ function escapeXml(value) {
49
+ return String(value)
50
+ // Strip the C0 control characters XML 1.0 forbids (everything below 0x20
51
+ // except tab, newline, and carriage return). A stray one from a corrupt
52
+ // DB-derived URL would otherwise make the WHOLE sitemap document invalid
53
+ // and silently rejected by search engines, the exact failure this helper
54
+ // exists to prevent. Done before entity-escaping so it cannot interfere.
55
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
56
+ .replace(/&/g, '&amp;')
57
+ .replace(/</g, '&lt;')
58
+ .replace(/>/g, '&gt;')
59
+ .replace(/"/g, '&quot;')
60
+ .replace(/'/g, '&apos;');
61
+ }
62
+
63
+ /**
64
+ * Format a `lastModified` value as a W3C datetime (ISO 8601) string. A Date
65
+ * becomes its `toISOString()`; a non-empty string passes through verbatim (the
66
+ * spec accepts both `YYYY-MM-DD` and a full timestamp). Anything else yields
67
+ * null so the `<lastmod>` element is omitted rather than emitted broken.
68
+ * @param {string|Date|undefined} value
69
+ * @returns {string|null}
70
+ */
71
+ function formatLastmod(value) {
72
+ if (value instanceof Date) {
73
+ const ms = value.getTime();
74
+ if (Number.isNaN(ms)) return null;
75
+ return value.toISOString();
76
+ }
77
+ if (typeof value === 'string' && value.trim() !== '') return value.trim();
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Validate + format a `priority` to one decimal in the 0.0..1.0 range. Returns
83
+ * null (so the element is dropped) for a non-number or an out-of-range value.
84
+ * @param {number|undefined} value
85
+ * @returns {string|null}
86
+ */
87
+ function formatPriority(value) {
88
+ if (typeof value !== 'number' || Number.isNaN(value)) return null;
89
+ if (value < 0 || value > 1) return null;
90
+ return value.toFixed(1);
91
+ }
92
+
93
+ /**
94
+ * Serialize an array of {@link SitemapEntry} into a complete `<urlset>` XML
95
+ * document. Element order per the protocol is loc, lastmod, changefreq,
96
+ * priority. A malformed entry (missing / non-string / empty `url`) is skipped so
97
+ * it cannot produce broken XML. Output is deterministic.
98
+ * @param {SitemapEntry[]} entries
99
+ * @returns {string} a `<?xml ?>` + `<urlset>` document
100
+ */
101
+ export function sitemap(entries) {
102
+ const list = Array.isArray(entries) ? entries : [];
103
+ if (list.length > MAX_URLS) {
104
+ console.warn(
105
+ `[webjs] sitemap() received ${list.length} URLs, over the ${MAX_URLS} per-file limit. ` +
106
+ 'Shard the site with sitemapIndex() instead. Emitting all URLs anyway.'
107
+ );
108
+ }
109
+ const urls = [];
110
+ for (const entry of list) {
111
+ if (!entry || typeof entry.url !== 'string' || entry.url.trim() === '') {
112
+ console.warn('[webjs] sitemap(): skipping an entry with a missing or invalid url');
113
+ continue;
114
+ }
115
+ const parts = [` <loc>${escapeXml(entry.url.trim())}</loc>`];
116
+ const lastmod = formatLastmod(entry.lastModified);
117
+ if (lastmod !== null) parts.push(` <lastmod>${escapeXml(lastmod)}</lastmod>`);
118
+ if (typeof entry.changeFrequency === 'string' && VALID_CHANGEFREQ.has(entry.changeFrequency)) {
119
+ parts.push(` <changefreq>${entry.changeFrequency}</changefreq>`);
120
+ }
121
+ const priority = formatPriority(entry.priority);
122
+ if (priority !== null) parts.push(` <priority>${priority}</priority>`);
123
+ urls.push(` <url>\n${parts.join('\n')}\n </url>`);
124
+ }
125
+ return (
126
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
127
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
128
+ (urls.length ? urls.join('\n') + '\n' : '') +
129
+ '</urlset>\n'
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Serialize an array of {@link SitemapIndexEntry} into a `<sitemapindex>` XML
135
+ * document, one `<sitemap>` per child sitemap URL. Same escaping + lastmod rules
136
+ * and same skip-malformed-entry behavior as {@link sitemap}. Use it from the
137
+ * root `app/sitemap.{js,ts}` to point at sharded child sitemaps served by
138
+ * `route.{js,ts}` handlers.
139
+ * @param {SitemapIndexEntry[]} sitemaps
140
+ * @returns {string} a `<?xml ?>` + `<sitemapindex>` document
141
+ */
142
+ export function sitemapIndex(sitemaps) {
143
+ const list = Array.isArray(sitemaps) ? sitemaps : [];
144
+ const items = [];
145
+ for (const entry of list) {
146
+ if (!entry || typeof entry.url !== 'string' || entry.url.trim() === '') {
147
+ console.warn('[webjs] sitemapIndex(): skipping an entry with a missing or invalid url');
148
+ continue;
149
+ }
150
+ const parts = [` <loc>${escapeXml(entry.url.trim())}</loc>`];
151
+ const lastmod = formatLastmod(entry.lastModified);
152
+ if (lastmod !== null) parts.push(` <lastmod>${escapeXml(lastmod)}</lastmod>`);
153
+ items.push(` <sitemap>\n${parts.join('\n')}\n </sitemap>`);
154
+ }
155
+ return (
156
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
157
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
158
+ (items.length ? items.join('\n') + '\n' : '') +
159
+ '</sitemapindex>\n'
160
+ );
161
+ }
package/src/testing.js ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Server-side test harness helpers (issue #267).
3
+ *
4
+ * `createRequestHandler(...).handle(request)` drives the FULL webjs pipeline
5
+ * (middleware, routing, SSR, page actions, server-action RPC, auth + CSRF), and
6
+ * the framework's own suite already drives it. These helpers are THIN builders
7
+ * over the native `Request` / `Response` around that `handle()`, so an app test
8
+ * reads as a one-liner instead of hand-assembling a `Request`, minting a CSRF
9
+ * pair, or serializing an action payload by hand.
10
+ *
11
+ * They are intentionally not a test framework: each is a few lines, returns
12
+ * native objects, and reuses the REAL cookie / header names (`csrf.js`) and the
13
+ * REAL wire serializer (`serializer.js` -> `@webjsdev/core`), never a parallel
14
+ * fake. The most robust auth helper drives the REAL login through `handle()` and
15
+ * captures the `Set-Cookie`, so it exercises the production cookie shape.
16
+ *
17
+ * Usage (a scaffolded app test):
18
+ *
19
+ * ```js
20
+ * import { createRequestHandler } from '@webjsdev/server';
21
+ * import { testRequest, getCsrf, invokeActionForTest } from '@webjsdev/server/testing';
22
+ *
23
+ * const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
24
+ *
25
+ * // 1. fire a request through the real pipeline
26
+ * const res = await testRequest(app.handle, '/about');
27
+ *
28
+ * // 2. round-trip an action through the real /__webjs/action/<hash>/<fn> path
29
+ * const out = await invokeActionForTest(app, 'modules/m/act.server.js', 'echo', [new Date()]);
30
+ * ```
31
+ *
32
+ * @module testing
33
+ */
34
+
35
+ import { hashFile, RPC_CONTENT_TYPE } from './actions.js';
36
+ import { CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
37
+ import { getSerializer } from './serializer.js';
38
+ import { join, sep } from 'node:path';
39
+
40
+ /**
41
+ * A `handle` is whatever `createRequestHandler(...).handle` is: a function
42
+ * taking a native `Request` and returning a `Promise<Response>`.
43
+ * @typedef {(req: Request) => Promise<Response>} Handle
44
+ */
45
+
46
+ /** Dummy origin used to turn a bare path (`/about`) into an absolute URL. */
47
+ const DUMMY_ORIGIN = 'http://webjs.test';
48
+
49
+ /**
50
+ * Coerce a path-or-Request `input` into a native `Request`.
51
+ *
52
+ * A string starting with `/` is prefixed with a dummy origin (the pipeline only
53
+ * reads `url.pathname` + `url.search`, so the origin is irrelevant); a full URL
54
+ * string is used as-is; a `Request` is passed through (with `init` overrides
55
+ * merged when provided).
56
+ *
57
+ * @param {string | Request} input
58
+ * @param {RequestInit} [init]
59
+ * @returns {Request}
60
+ */
61
+ export function toRequest(input, init) {
62
+ if (input instanceof Request) {
63
+ return init ? new Request(input, init) : input;
64
+ }
65
+ const url = input.startsWith('/') ? DUMMY_ORIGIN + input : input;
66
+ return new Request(url, init);
67
+ }
68
+
69
+ /**
70
+ * Fire a request through the real `handle()` pipeline and return the `Response`.
71
+ *
72
+ * The documented one-liner: `const res = await testRequest(app.handle, '/about')`.
73
+ * Accepts a bare path (prefixed with a dummy origin), a full URL string, or a
74
+ * pre-built `Request`. `init` is the standard `RequestInit` (method, headers,
75
+ * body, ...).
76
+ *
77
+ * @param {Handle} handle
78
+ * @param {string | Request} input
79
+ * @param {RequestInit} [init]
80
+ * @returns {Promise<Response>}
81
+ */
82
+ export function testRequest(handle, input, init) {
83
+ return handle(toRequest(input, init));
84
+ }
85
+
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
+ /**
105
+ * Collect every `Set-Cookie` value off a `Response`, preferring the
106
+ * structured `getSetCookie()` (which keeps multiple cookies separate) and
107
+ * falling back to the single combined header.
108
+ *
109
+ * @param {Response} res
110
+ * @returns {string[]}
111
+ */
112
+ export function getSetCookies(res) {
113
+ const h = res.headers;
114
+ if (typeof h.getSetCookie === 'function') {
115
+ const list = h.getSetCookie();
116
+ if (list && list.length) return list;
117
+ }
118
+ const single = h.get('set-cookie');
119
+ return single ? [single] : [];
120
+ }
121
+
122
+ /**
123
+ * Turn a list of `Set-Cookie` header values into a single `Cookie` request
124
+ * header value (`name=value; name2=value2`). Strips the cookie attributes
125
+ * (`Path`, `Max-Age`, ...), keeping only the `name=value` pair of each.
126
+ *
127
+ * @param {string[]} setCookies
128
+ * @returns {string}
129
+ */
130
+ export function cookiesToHeader(setCookies) {
131
+ return setCookies
132
+ .map((c) => c.split(';', 1)[0].trim())
133
+ .filter(Boolean)
134
+ .join('; ');
135
+ }
136
+
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
+ /**
164
+ * Merge an extra cookie string into a `RequestInit`'s `Cookie` header,
165
+ * preserving any cookie already on `init.headers`.
166
+ *
167
+ * @param {RequestInit} [init]
168
+ * @param {string} cookieValue a `name=value; name2=value2` string
169
+ * @returns {RequestInit}
170
+ */
171
+ export function withCookies(init = {}, cookieValue) {
172
+ const headers = new Headers(init.headers || {});
173
+ const existing = headers.get('cookie');
174
+ headers.set('cookie', existing ? `${existing}; ${cookieValue}` : cookieValue);
175
+ return { ...init, headers };
176
+ }
177
+
178
+ /**
179
+ * Attach an auth/session cookie (captured from a real login `Set-Cookie`) to a
180
+ * `RequestInit` so a follow-up request hits a protected route as the logged-in
181
+ * user. A thin convenience over `withCookies`; the cookie value comes from the
182
+ * REAL session mechanism (see `loginAndGetCookies`), never a fabricated shape.
183
+ *
184
+ * @param {RequestInit} [init]
185
+ * @param {string} sessionCookie a `Cookie` header value (e.g. from `loginAndGetCookies`)
186
+ * @returns {RequestInit}
187
+ */
188
+ export function withSessionCookie(init = {}, sessionCookie) {
189
+ return withCookies(init, sessionCookie);
190
+ }
191
+
192
+ /**
193
+ * Drive the REAL credentials login through `handle()` and return the resulting
194
+ * cookies as a ready-to-reuse `Cookie` request-header value, so a test can hit a
195
+ * protected route as a logged-in user. This exercises the production auth flow
196
+ * (the `app/api/auth/[...path]/route.ts` handler + `createAuth`'s
197
+ * `Set-Cookie`), so the captured cookie is the genuine signed session cookie,
198
+ * not a fabricated one.
199
+ *
200
+ * Defaults target `createAuth`'s credentials sign-in route
201
+ * (`POST /api/auth/signin/credentials`, the path its handler actually routes a
202
+ * credentials login through), posting a urlencoded `email` + `password`.
203
+ * Override `loginPath` / `body` / `method` for a different auth wiring.
204
+ *
205
+ * @param {Handle} handle
206
+ * @param {{ email: string, password: string }} credentials
207
+ * @param {{
208
+ * loginPath?: string,
209
+ * method?: string,
210
+ * body?: BodyInit,
211
+ * contentType?: string,
212
+ * expectStatuses?: number[],
213
+ * }} [opts]
214
+ * @returns {Promise<{ cookies: string, setCookies: string[], response: Response }>}
215
+ * `cookies` is the combined `Cookie` header value; `setCookies` is the raw
216
+ * `Set-Cookie` list; `response` is the login response (a 302 on success).
217
+ */
218
+ export async function loginAndGetCookies(handle, credentials, opts = {}) {
219
+ const loginPath = opts.loginPath || '/api/auth/signin/credentials';
220
+ const method = opts.method || 'POST';
221
+ const contentType = opts.contentType || 'application/x-www-form-urlencoded';
222
+ const body =
223
+ opts.body != null
224
+ ? opts.body
225
+ : new URLSearchParams({
226
+ email: credentials.email,
227
+ password: credentials.password,
228
+ }).toString();
229
+
230
+ const res = await testRequest(handle, loginPath, {
231
+ method,
232
+ headers: { 'content-type': contentType },
233
+ body,
234
+ });
235
+
236
+ const setCookies = getSetCookies(res);
237
+ const cookies = cookiesToHeader(setCookies);
238
+ if (!cookies) {
239
+ throw new Error(
240
+ `loginAndGetCookies: POST ${loginPath} returned no Set-Cookie (status ` +
241
+ `${res.status}). The credentials were likely rejected, or the login path ` +
242
+ `is wrong for this app's auth wiring (override opts.loginPath / opts.body).`,
243
+ );
244
+ }
245
+ return { cookies, setCookies, response: res };
246
+ }
247
+
248
+ /**
249
+ * Compute the RPC endpoint path for an action, addressing it the same way the
250
+ * generated client stub does: by the SHA-256 hash of the action's ABSOLUTE file
251
+ * path (`hashFile` in `actions.js`, the single source of truth) plus the
252
+ * exported function name.
253
+ *
254
+ * `serverFilePath` may be absolute, or relative to `appDir` (joined with the
255
+ * OS separator, matching `resolveServerModule`).
256
+ *
257
+ * @param {string} appDir the app root passed to `createRequestHandler`
258
+ * @param {string} serverFilePath the `.server.{js,ts}` file (absolute or appDir-relative)
259
+ * @param {string} fnName the exported action function name
260
+ * @returns {Promise<string>} the path, e.g. `/__webjs/action/<hash>/<fn>`
261
+ */
262
+ export async function actionEndpoint(appDir, serverFilePath, fnName) {
263
+ const abs = isAbsolutePath(serverFilePath)
264
+ ? serverFilePath
265
+ : join(appDir, serverFilePath.split('/').join(sep));
266
+ const hash = await hashFile(abs);
267
+ return `/__webjs/action/${hash}/${fnName}`;
268
+ }
269
+
270
+ /** @param {string} p */
271
+ function isAbsolutePath(p) {
272
+ return p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p);
273
+ }
274
+
275
+ /**
276
+ * Round-trip a registered server action through the REAL
277
+ * `/__webjs/action/<hash>/<fn>` endpoint: serialize `args` with the webjs
278
+ * serializer (exactly as the client stub does), POST them with a valid CSRF
279
+ * cookie + header, and parse the response with the serializer.
280
+ *
281
+ * Unlike a DIRECT import of the action (which calls the function in-process),
282
+ * this exercises the production path: CSRF validation, the wire serializer
283
+ * round-trip (so a `Date` / `Map` / `BigInt` arg or return is genuinely
284
+ * encoded + decoded), and prod error sanitization (a thrown error surfaces as a
285
+ * sanitized message, never the stack). That is why it catches a
286
+ * serializer / CSRF / error-sanitization regression a direct import would miss.
287
+ *
288
+ * The action is addressed via `actionEndpoint` (the file-path hash + fn name),
289
+ * the same scheme the generated stub uses.
290
+ *
291
+ * On a non-2xx response the parsed `error` is thrown as an `Error` whose
292
+ * `status` property carries the HTTP status, mirroring the client stub's
293
+ * behavior (so a CSRF 403 or a sanitized 500 surfaces as a throw a test can
294
+ * assert on).
295
+ *
296
+ * @param {{ handle: Handle, appDir: string } | Handle} app
297
+ * the handler object (`{ handle, appDir }`, e.g. the `createRequestHandler`
298
+ * return) or, when you pass `opts.appDir` and `opts.endpoint`, just the
299
+ * `handle` function.
300
+ * @param {string} serverFilePath the `.server.{js,ts}` action file (absolute or appDir-relative)
301
+ * @param {string} fnName the exported action function name
302
+ * @param {unknown[]} [args] positional args (serialized as the stub sends an arg array)
303
+ * @param {{
304
+ * csrf?: { cookie: string, token: string },
305
+ * appDir?: string,
306
+ * extraCookies?: string,
307
+ * throwOnError?: boolean,
308
+ * }} [opts]
309
+ * @returns {Promise<any>} the parsed action return value
310
+ */
311
+ export async function invokeActionForTest(app, serverFilePath, fnName, args = [], opts = {}) {
312
+ const handle = typeof app === 'function' ? app : app.handle;
313
+ const appDir = opts.appDir || (typeof app === 'object' ? app.appDir : undefined);
314
+ if (!appDir) {
315
+ throw new Error(
316
+ 'invokeActionForTest: appDir unknown. Pass the createRequestHandler ' +
317
+ 'return value (which carries appDir), or set opts.appDir.',
318
+ );
319
+ }
320
+ const throwOnError = opts.throwOnError !== false;
321
+
322
+ const endpoint = await actionEndpoint(appDir, serverFilePath, fnName);
323
+ const csrf = opts.csrf || (await getCsrf(handle));
324
+
325
+ const serializer = getSerializer();
326
+ const body = await serializer.serialize(args);
327
+
328
+ let cookie = csrf.cookie || `${CSRF_COOKIE}=${encodeURIComponent(csrf.token)}`;
329
+ if (opts.extraCookies) cookie = `${cookie}; ${opts.extraCookies}`;
330
+
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
+ });
340
+
341
+ const text = await res.text();
342
+ const ct = res.headers.get('content-type') || '';
343
+ let parsed;
344
+ if (ct.includes(RPC_CONTENT_TYPE)) parsed = serializer.deserialize(text);
345
+ else if (ct.includes('application/json')) parsed = text ? JSON.parse(text) : null;
346
+ else parsed = text;
347
+
348
+ if (!res.ok && throwOnError) {
349
+ const msg = (parsed && parsed.error) || `webjs action ${fnName} -> ${res.status}`;
350
+ const err = /** @type {Error & { status?: number, response?: Response }} */ (new Error(msg));
351
+ err.status = res.status;
352
+ err.response = res;
353
+ throw err;
354
+ }
355
+ return parsed;
356
+ }
357
+
358
+ /**
359
+ * 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.
363
+ *
364
+ * @param {{ handle: Handle, appDir: string } | Handle} app
365
+ * @param {string} serverFilePath
366
+ * @param {string} fnName
367
+ * @param {unknown[]} [args]
368
+ * @param {{
369
+ * csrf?: { cookie: string, token: string } | null,
370
+ * omitCsrf?: boolean,
371
+ * appDir?: string,
372
+ * extraCookies?: string,
373
+ * contentType?: string,
374
+ * }} [opts]
375
+ * @returns {Promise<Response>}
376
+ */
377
+ export async function rawActionRequest(app, serverFilePath, fnName, args = [], opts = {}) {
378
+ const handle = typeof app === 'function' ? app : app.handle;
379
+ const appDir = opts.appDir || (typeof app === 'object' ? app.appDir : undefined);
380
+ if (!appDir) {
381
+ throw new Error('rawActionRequest: appDir unknown. Pass the handler object or set opts.appDir.');
382
+ }
383
+ const endpoint = await actionEndpoint(appDir, serverFilePath, fnName);
384
+ const serializer = getSerializer();
385
+ const body = await serializer.serialize(args);
386
+
387
+ /** @type {Record<string,string>} */
388
+ 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;
397
+ }
398
+
399
+ return testRequest(handle, endpoint, { method: 'POST', headers, body });
400
+ }
package/src/vendor.js CHANGED
@@ -462,6 +462,7 @@ export async function vendorImportMapEntries(bareImports, appDir) {
462
462
  */
463
463
  export function clearVendorCache() {
464
464
  jspmCache.clear();
465
+ liveIntegrityCache.clear();
465
466
  }
466
467
 
467
468
  // ---------------------------------------------------------------------------
@@ -1299,10 +1300,12 @@ function maxSemverVersion(versions) {
1299
1300
  * 2. Live api.jspm.io/generate (fallback when no pin file exists)
1300
1301
  *
1301
1302
  * Returns both `imports` (the URL map) and `integrity` (SRI hashes
1302
- * keyed by URL). Integrity is populated only from the pin file;
1303
- * live-API mode skips it (would require per-package fetches just to
1304
- * hash, defeating the live-mode speed advantage. Users who want SRI
1305
- * run `webjs vendor pin`).
1303
+ * keyed by the FINAL URL). Integrity is populated on BOTH paths (#235):
1304
+ * the pin file supplies it directly, and the live-API path now hashes
1305
+ * each cross-origin bundle after resolving (bounded + fail-open, see
1306
+ * `computeLiveIntegrity`), so an unpinned app also serves SRI. A fetch
1307
+ * failure for one URL degrades to a missing hash for that URL plus a
1308
+ * one-time warning, never a broken resolve.
1306
1309
  *
1307
1310
  * @param {string} appDir
1308
1311
  * @param {() => Promise<Set<string>>} getBareImports lazy scan, invoked ONLY
@@ -1358,6 +1361,130 @@ export function prunePinToReachable(imports, integrity, reachable) {
1358
1361
  return { imports: keptImports, integrity: keptIntegrity };
1359
1362
  }
1360
1363
 
1364
+ /**
1365
+ * Per-process cache of SHA-384 integrity hashes for live-resolved vendor
1366
+ * URLs, keyed by the FINAL cross-origin URL. A vendor bundle at a given
1367
+ * versioned URL is immutable, so once hashed it never needs re-fetching
1368
+ * within the process: a re-resolve (e.g. after a file-watcher rebuild
1369
+ * that did not change the dep) reuses the hash instead of re-downloading.
1370
+ * Cleared by `clearVendorCache` alongside the jspm fragment cache so a
1371
+ * version bump re-hashes. This is NOT a persistent cache (that is the pin
1372
+ * file's job); it only avoids redundant fetches in one running process.
1373
+ *
1374
+ * @type {Map<string, string>}
1375
+ */
1376
+ const liveIntegrityCache = new Map();
1377
+
1378
+ const INTEGRITY_FETCH_TIMEOUT_MS = 10_000;
1379
+ // Cap concurrent bundle fetches so a large dep set does not open dozens of
1380
+ // sockets at once during warmup. Matches the bounded posture of the rest of
1381
+ // vendor.js (the jspm resolve is per-package but the network is the shared
1382
+ // constraint).
1383
+ const INTEGRITY_FETCH_CONCURRENCY = 6;
1384
+ // Total wall-clock budget for the whole live-integrity hashing phase. It runs
1385
+ // inside the readiness-gating warmup, so even a CDN that serves the importmap
1386
+ // then hangs on every bundle GET must not stall the first request for the sum
1387
+ // of per-fetch timeouts. Once the budget passes, the remaining URLs are left
1388
+ // without integrity (the same fail-open fallback as a fetch failure) instead of
1389
+ // waiting out a 10s timeout each. A healthy CDN finishes in well under this.
1390
+ const INTEGRITY_TOTAL_BUDGET_MS = 15_000;
1391
+
1392
+ /**
1393
+ * Fetch a single cross-origin URL with a bounded timeout and return its
1394
+ * SHA-384 SRI hash, or null on any failure (network, timeout, non-ok).
1395
+ * Fail-OPEN by design: a CDN hiccup must never break warmup, so a failure
1396
+ * is a skipped hash (the URL serves without `integrity`, the pre-#235
1397
+ * behavior for that one URL), not a thrown error.
1398
+ *
1399
+ * @param {string} url
1400
+ * @returns {Promise<string | null>}
1401
+ */
1402
+ async function fetchLiveIntegrity(url) {
1403
+ const cached = liveIntegrityCache.get(url);
1404
+ if (cached) return cached;
1405
+ const controller = new AbortController();
1406
+ const timer = setTimeout(() => controller.abort(), INTEGRITY_FETCH_TIMEOUT_MS);
1407
+ try {
1408
+ const response = await fetch(url, { signal: controller.signal });
1409
+ if (!response.ok) return null;
1410
+ // Hash the raw response bytes (arrayBuffer -> Uint8Array), the same
1411
+ // primitive the browser's SRI implementation hashes. Decoding to a
1412
+ // string first would risk encoding round-trip drift. See the matching
1413
+ // comment in downloadBundle / fetchIntegrity.
1414
+ const buf = new Uint8Array(await response.arrayBuffer());
1415
+ const sri = await sha384Integrity(buf);
1416
+ liveIntegrityCache.set(url, sri);
1417
+ return sri;
1418
+ } catch {
1419
+ return null;
1420
+ } finally {
1421
+ clearTimeout(timer);
1422
+ }
1423
+ }
1424
+
1425
+ /**
1426
+ * Compute SRI integrity for the CROSS-ORIGIN targets of a live-resolved
1427
+ * import map. Same-origin targets (the `@webjsdev/core/*` runtime under
1428
+ * `/__webjs/core/...` and any local `/__webjs/vendor/...` bundle) are
1429
+ * skipped: they are served by the framework and already trusted, and SRI
1430
+ * is a cross-origin defense.
1431
+ *
1432
+ * The returned map is keyed by the FINAL URL (the import-map target
1433
+ * value), matching `vendorIntegrityFor(url)`'s lookup key so ssr.js emits
1434
+ * the `integrity` sibling for free.
1435
+ *
1436
+ * Bounded and fail-open: cross-origin bundles are fetched in parallel with
1437
+ * a small concurrency cap and a per-fetch timeout, and a failed fetch is
1438
+ * skipped (no integrity for that one URL) rather than breaking the resolve.
1439
+ * A single one-time `console.warn` reports the count of URLs that could not
1440
+ * be hashed (no per-URL spam).
1441
+ *
1442
+ * @param {Record<string, string>} imports specifier -> final URL
1443
+ * @returns {Promise<Record<string, string>>} integrity keyed by final URL
1444
+ */
1445
+ async function computeLiveIntegrity(imports) {
1446
+ // De-duplicate by URL: two specifiers can resolve to the same bundle URL
1447
+ // (a bare import and one of its subpaths), so hash each URL once.
1448
+ const urls = [...new Set(Object.values(imports))].filter((u) => /^https:\/\//.test(u));
1449
+ /** @type {Record<string, string>} */
1450
+ const integrity = {};
1451
+ if (urls.length === 0) return integrity;
1452
+
1453
+ const failed = [];
1454
+ let next = 0;
1455
+ const deadline = Date.now() + INTEGRITY_TOTAL_BUDGET_MS;
1456
+ async function worker() {
1457
+ // Stop claiming new URLs once the total budget passes; a URL already
1458
+ // in flight still settles under its own per-fetch timeout.
1459
+ while (next < urls.length && Date.now() < deadline) {
1460
+ const url = urls[next++];
1461
+ const sri = await fetchLiveIntegrity(url);
1462
+ if (sri) integrity[url] = sri;
1463
+ else failed.push(url);
1464
+ }
1465
+ }
1466
+ const workerCount = Math.min(INTEGRITY_FETCH_CONCURRENCY, urls.length);
1467
+ await Promise.all(Array.from({ length: workerCount }, worker));
1468
+ // Any URL never claimed because the budget passed also fails open (served
1469
+ // without integrity), the same outcome as a fetch failure.
1470
+ while (next < urls.length) failed.push(urls[next++]);
1471
+
1472
+ if (failed.length) {
1473
+ // One-time, count-based warning. The app still boots and the imports
1474
+ // still work; only these URLs lack SRI (served as before #235). Run
1475
+ // `webjs vendor pin` to lock in integrity, or retry once the CDN is
1476
+ // healthy. Naming one example URL aids diagnosis without per-URL spam.
1477
+ console.warn(
1478
+ `[webjs] could not compute SRI for ${failed.length} live-resolved ` +
1479
+ `vendor URL(s) (e.g. ${failed[0]}); they will load WITHOUT integrity. ` +
1480
+ `This is a fail-open fallback for a CDN fetch failure or the warmup ` +
1481
+ `time budget; the app still works. Run \`webjs vendor pin\` to lock in ` +
1482
+ `SRI hashes.`,
1483
+ );
1484
+ }
1485
+ return integrity;
1486
+ }
1487
+
1361
1488
  export async function resolveVendorImports(appDir, getBareImports) {
1362
1489
  const file = await readPinFile(appDir);
1363
1490
  // A committed pin file IS the import map. The whole-app bare-import scan is
@@ -1366,16 +1493,26 @@ export async function resolveVendorImports(appDir, getBareImports) {
1366
1493
  // solely here, only when there is no pin file.
1367
1494
  if (file) {
1368
1495
  // A pin file is a deterministic disk read: always "ok" (no live CDN call
1369
- // that could partially fail). This is the recommended prod posture.
1496
+ // that could partially fail). This is the recommended prod posture. The
1497
+ // pin's own integrity is used verbatim; the live-hash path below is NOT
1498
+ // taken for a pinned app.
1370
1499
  return { imports: file.imports, integrity: file.integrity || {}, ok: true };
1371
1500
  }
1372
1501
  lastLiveResolveFailed = false;
1373
1502
  const bareImports = await getBareImports();
1374
1503
  const imports = await vendorImportMapEntries(bareImports, appDir);
1504
+ // Fill the SRI gap for live-resolved (unpinned) apps (#235): hash each
1505
+ // cross-origin bundle and key the integrity by its final URL, the same
1506
+ // shape the pin path uses and `vendorIntegrityFor` looks up. Bounded +
1507
+ // fail-open, so a CDN fetch failure degrades to a missing hash for that
1508
+ // URL (a warning), never a broken resolve. This runs only AFTER a live
1509
+ // resolve produced URLs; if the resolve itself failed there is nothing to
1510
+ // hash.
1511
+ const integrity = await computeLiveIntegrity(imports);
1375
1512
  // ok=false means at least one install could not be resolved (CDN unreachable
1376
1513
  // / timeout / non-ok), so `imports` is partial. The caller must not memoize
1377
1514
  // this as done; it should retry once the CDN recovers.
1378
- return { imports, integrity: {}, ok: !lastLiveResolveFailed };
1515
+ return { imports, integrity, ok: !lastLiveResolveFailed };
1379
1516
  }
1380
1517
 
1381
1518
  /**