what-server 0.10.0 → 0.11.0

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/dist/index.js CHANGED
@@ -44,7 +44,7 @@ async function revalidatePath(path, options) {
44
44
  if (_handler && _handler.revalidatePath) return _handler.revalidatePath(path, options);
45
45
  if (isDev) {
46
46
  console.warn(
47
- `[what] revalidatePath('${path}') had no effect: no cache engine is bound. Create a what-cache engine and bind it in your adapter (setRevalidationHandler).`
47
+ `[what] revalidatePath('${path}') had no effect: no cache engine is bound. Create a what-isr engine and bind it in your adapter (setRevalidationHandler).`
48
48
  );
49
49
  }
50
50
  }
@@ -426,6 +426,39 @@ function jsonResponse(status, bodyObj) {
426
426
  body: JSON.stringify(bodyObj)
427
427
  };
428
428
  }
429
+ function htmlResponse(status, message) {
430
+ return {
431
+ status,
432
+ headers: { "content-type": "text/html; charset=utf-8" },
433
+ body: `<!DOCTYPE html><html><body><h1>${status}</h1><p>${String(message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</p></body></html>`
434
+ };
435
+ }
436
+ function safeLocalPath(value) {
437
+ if (typeof value !== "string" || !value.startsWith("/")) return null;
438
+ if (/^[/\\]{2}/.test(value) || value.includes("\\")) return null;
439
+ try {
440
+ const u = new URL(value, "http://localhost");
441
+ if (u.origin !== "http://localhost") return null;
442
+ return u.pathname + u.search;
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+ function safeRedirectTarget(form, headers) {
448
+ const explicit = safeLocalPath(form && form._redirect);
449
+ if (explicit) return explicit;
450
+ const referer = headers.referer || headers.referrer;
451
+ if (referer) {
452
+ try {
453
+ const u = new URL(referer, "http://localhost");
454
+ const path = safeLocalPath(u.pathname + u.search);
455
+ if (path) return path;
456
+ } catch {
457
+ }
458
+ }
459
+ return "/";
460
+ }
461
+ var RESERVED_FORM_FIELDS = /* @__PURE__ */ new Set(["_action", "data-action", "_csrf", "_redirect"]);
429
462
  function createActionHandler(options = {}) {
430
463
  const { getCsrfToken: getCsrfToken2, skipCsrf = false } = options;
431
464
  return async function handle(reqLike) {
@@ -434,16 +467,51 @@ function createActionHandler(options = {}) {
434
467
  return jsonResponse(405, { message: "Method Not Allowed" });
435
468
  }
436
469
  const headers = lowerHeaders(reqLike.headers);
437
- const actionId = headers["x-what-action"];
438
- if (!actionId) {
470
+ const headerActionId = headers["x-what-action"];
471
+ const contentType = headers["content-type"] || "";
472
+ const isFormPost = !headerActionId && contentType.includes("application/x-www-form-urlencoded");
473
+ const sessionCsrfToken = skipCsrf ? void 0 : getCsrfToken2 ? await getCsrfToken2(reqLike) : void 0;
474
+ if (isFormPost) {
475
+ const form = reqLike.body || {};
476
+ const actionId = form._action || form["data-action"] || reqLike.query && reqLike.query.action;
477
+ if (!actionId) {
478
+ return htmlResponse(400, 'Missing action name (add a hidden "_action" field or ?action= query param)');
479
+ }
480
+ const formHeaders = { ...headers };
481
+ if (form._csrf && !formHeaders["x-csrf-token"]) formHeaders["x-csrf-token"] = String(form._csrf);
482
+ if (!skipCsrf && getCsrfToken2 && !sessionCsrfToken) {
483
+ return htmlResponse(403, "Missing CSRF token");
484
+ }
485
+ const data = {};
486
+ for (const [k, v] of Object.entries(form)) {
487
+ if (!RESERVED_FORM_FIELDS.has(k)) data[k] = v;
488
+ }
489
+ const result2 = await handleActionRequest(
490
+ { headers: formHeaders },
491
+ actionId,
492
+ [data],
493
+ { csrfToken: sessionCsrfToken, skipCsrf }
494
+ );
495
+ if (result2.status === 200) {
496
+ return {
497
+ status: 303,
498
+ headers: { location: safeRedirectTarget(form, headers) },
499
+ body: ""
500
+ };
501
+ }
502
+ return htmlResponse(result2.status, result2.body && result2.body.message || "Action failed");
503
+ }
504
+ if (!headerActionId) {
439
505
  return jsonResponse(400, { message: "Missing X-What-Action header" });
440
506
  }
507
+ if (!skipCsrf && getCsrfToken2 && !sessionCsrfToken) {
508
+ return jsonResponse(403, { message: "Missing CSRF token" });
509
+ }
441
510
  const body = reqLike.body || {};
442
511
  const args = body.args;
443
- const sessionCsrfToken = skipCsrf ? void 0 : getCsrfToken2 ? await getCsrfToken2(reqLike) : void 0;
444
512
  const result = await handleActionRequest(
445
513
  { headers },
446
- actionId,
514
+ headerActionId,
447
515
  args,
448
516
  { csrfToken: sessionCsrfToken, skipCsrf }
449
517
  );
@@ -454,24 +522,70 @@ function nodeActionMiddleware(options = {}) {
454
522
  const basePath = options.basePath || DEFAULT_BASE_PATH;
455
523
  const handle = createActionHandler(options);
456
524
  return async function middleware(req, res, next) {
457
- const url = (req.url || "").split("?")[0];
525
+ const [url, search] = (req.url || "").split("?");
458
526
  if (url !== basePath || (req.method || "").toUpperCase() !== "POST") {
459
527
  return next ? next() : void 0;
460
528
  }
461
529
  let body;
462
530
  try {
463
- body = await readJsonBody(req);
531
+ const raw = await readRawBody(req);
532
+ body = parseActionBody(raw, req.headers["content-type"] || "");
464
533
  } catch (err) {
465
534
  res.writeHead(err.code === "BODY_TOO_LARGE" ? 413 : 400, { "content-type": "application/json" });
466
- res.end(JSON.stringify({ message: err.code === "BODY_TOO_LARGE" ? "Payload too large" : "Invalid JSON body" }));
535
+ res.end(JSON.stringify({ message: err.code === "BODY_TOO_LARGE" ? "Payload too large" : "Invalid request body" }));
467
536
  return;
468
537
  }
469
- const out = await handle({ method: req.method, headers: req.headers, body });
538
+ const query = Object.fromEntries(new URLSearchParams(search || ""));
539
+ const out = await handle({ method: req.method, headers: req.headers, body, query });
470
540
  res.writeHead(out.status, out.headers);
471
541
  res.end(out.body);
472
542
  };
473
543
  }
474
- function readJsonBody(req) {
544
+ function parseActionBody(raw, contentType) {
545
+ if ((contentType || "").includes("application/x-www-form-urlencoded")) {
546
+ const fields = {};
547
+ for (const [k, v] of new URLSearchParams(String(raw))) {
548
+ if (fields[k] === void 0) fields[k] = v;
549
+ else if (Array.isArray(fields[k])) fields[k].push(v);
550
+ else fields[k] = [fields[k], v];
551
+ }
552
+ return fields;
553
+ }
554
+ if (raw == null || raw === "") return {};
555
+ return JSON.parse(String(raw));
556
+ }
557
+ async function readFetchBodyCapped(request, limit = MAX_BODY_BYTES) {
558
+ const declared = Number(request.headers.get("content-length"));
559
+ if (Number.isFinite(declared) && declared > limit) {
560
+ return { tooLarge: true };
561
+ }
562
+ const body = request.body;
563
+ if (!body || typeof body.getReader !== "function") {
564
+ const raw = await request.text();
565
+ if (Buffer.byteLength(raw, "utf8") > limit) return { tooLarge: true };
566
+ return { raw };
567
+ }
568
+ const reader = body.getReader();
569
+ const chunks = [];
570
+ let size = 0;
571
+ while (true) {
572
+ const { done, value } = await reader.read();
573
+ if (done) break;
574
+ if (value) {
575
+ size += value.byteLength;
576
+ if (size > limit) {
577
+ try {
578
+ await reader.cancel();
579
+ } catch {
580
+ }
581
+ return { tooLarge: true };
582
+ }
583
+ chunks.push(value);
584
+ }
585
+ }
586
+ return { raw: Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8") };
587
+ }
588
+ function readRawBody(req) {
475
589
  return new Promise((resolve, reject) => {
476
590
  let size = 0;
477
591
  const chunks = [];
@@ -487,12 +601,8 @@ function readJsonBody(req) {
487
601
  chunks.push(chunk);
488
602
  });
489
603
  req.on("end", () => {
490
- if (chunks.length === 0) return resolve({});
491
- try {
492
- resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
493
- } catch (e) {
494
- reject(e);
495
- }
604
+ if (chunks.length === 0) return resolve("");
605
+ resolve(Buffer.concat(chunks).toString("utf8"));
496
606
  });
497
607
  req.on("error", reject);
498
608
  });
@@ -502,11 +612,23 @@ function fetchActionHandler(options = {}) {
502
612
  return async function(request) {
503
613
  let body = {};
504
614
  try {
505
- body = await request.json();
615
+ const read = await readFetchBodyCapped(request);
616
+ if (read.tooLarge) {
617
+ return new Response(JSON.stringify({ message: "Payload too large" }), {
618
+ status: 413,
619
+ headers: { "content-type": "application/json" }
620
+ });
621
+ }
622
+ body = parseActionBody(read.raw, request.headers.get("content-type") || "");
506
623
  } catch {
507
624
  body = {};
508
625
  }
509
- const out = await handle({ method: request.method, headers: request.headers, body });
626
+ let query = {};
627
+ try {
628
+ query = Object.fromEntries(new URL(request.url, "http://localhost").searchParams);
629
+ } catch {
630
+ }
631
+ const out = await handle({ method: request.method, headers: request.headers, body, query });
510
632
  return new Response(out.body, { status: out.status, headers: out.headers });
511
633
  };
512
634
  }
@@ -515,6 +637,7 @@ function fetchActionHandler(options = {}) {
515
637
  import { matchRoute, parseQuery } from "what-router/match";
516
638
  var ACTION_PATH = "/__what_action";
517
639
  var REVALIDATE_PATH = "/__what_revalidate";
640
+ var CSRF_COOKIE = "what-csrf";
518
641
  function headersToObject(headers) {
519
642
  const out = {};
520
643
  if (headers && typeof headers.forEach === "function") headers.forEach((v, k) => {
@@ -522,7 +645,21 @@ function headersToObject(headers) {
522
645
  });
523
646
  return out;
524
647
  }
525
- async function readJsonBody2(request) {
648
+ function readCookie(cookieHeader, name) {
649
+ if (!cookieHeader) return null;
650
+ const match = String(cookieHeader).match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
651
+ return match ? decodeURIComponent(match[1]) : null;
652
+ }
653
+ async function readActionBody(request) {
654
+ try {
655
+ const read = await readFetchBodyCapped(request);
656
+ if (read.tooLarge) return { tooLarge: true };
657
+ return parseActionBody(read.raw, request.headers.get("content-type") || "");
658
+ } catch {
659
+ return {};
660
+ }
661
+ }
662
+ async function readJsonBody(request) {
526
663
  try {
527
664
  return await request.json();
528
665
  } catch {
@@ -533,7 +670,8 @@ function defaultRenderRoute(documentOptions) {
533
670
  return async function renderRoute(routeMatch) {
534
671
  const { route, params, query, request } = routeMatch;
535
672
  const pageModule = { default: route.component, loader: route.loader };
536
- const html = await renderDocument(pageModule, { params, query, request }, documentOptions);
673
+ const opts = routeMatch.csrfToken ? { ...documentOptions, csrfToken: routeMatch.csrfToken } : documentOptions;
674
+ const html = await renderDocument(pageModule, { params, query, request }, opts);
537
675
  return {
538
676
  html,
539
677
  status: 200,
@@ -547,12 +685,16 @@ function createRequestHandler(options = {}) {
547
685
  routes = [],
548
686
  cache,
549
687
  render,
550
- actionHandler = createActionHandler({ skipCsrf: true }),
551
688
  revalidateWebhook,
552
689
  document: documentOptions = {},
553
690
  notFound,
554
- basePath = ""
691
+ basePath = "",
692
+ csrf = true
555
693
  } = options;
694
+ const autoCsrf = csrf !== false && !options.actionHandler;
695
+ const actionHandler = options.actionHandler || createActionHandler(
696
+ autoCsrf ? { getCsrfToken: (reqLike) => readCookie(reqLike.headers && reqLike.headers.cookie, CSRF_COOKIE) } : { skipCsrf: true }
697
+ );
556
698
  const renderRoute = render || defaultRenderRoute(documentOptions);
557
699
  if (cache && (cache.revalidatePath || cache.revalidateTag)) {
558
700
  setRevalidationHandler({
@@ -565,12 +707,38 @@ function createRequestHandler(options = {}) {
565
707
  let pathname = url.pathname;
566
708
  if (basePath && pathname.startsWith(basePath)) pathname = pathname.slice(basePath.length) || "/";
567
709
  if (request.method === "POST" && pathname === ACTION_PATH) {
568
- const body = await readJsonBody2(request);
569
- const out2 = await actionHandler({ method: "POST", headers: headersToObject(request.headers), body });
710
+ const body = await readActionBody(request);
711
+ if (body && body.tooLarge) {
712
+ return new Response(JSON.stringify({ message: "Payload too large" }), {
713
+ status: 413,
714
+ headers: { "content-type": "application/json" }
715
+ });
716
+ }
717
+ const out2 = await actionHandler({
718
+ method: "POST",
719
+ headers: headersToObject(request.headers),
720
+ body,
721
+ query: Object.fromEntries(url.searchParams)
722
+ });
570
723
  return new Response(out2.body, { status: out2.status, headers: out2.headers });
571
724
  }
725
+ let csrfToken = null;
726
+ let csrfSetCookie = null;
727
+ if (autoCsrf) {
728
+ csrfToken = readCookie(headersToObject(request.headers).cookie, CSRF_COOKIE);
729
+ if (!csrfToken) {
730
+ csrfToken = generateCsrfToken();
731
+ const reqHeaders = headersToObject(request.headers);
732
+ const isHttps = reqHeaders["x-forwarded-proto"] === "https" || url.protocol === "https:" || false;
733
+ csrfSetCookie = `${CSRF_COOKIE}=${encodeURIComponent(csrfToken)}; Path=/; SameSite=Lax` + (isHttps ? "; Secure" : "");
734
+ }
735
+ }
736
+ const withCsrfCookie = (headers2) => {
737
+ if (csrfSetCookie) headers2["set-cookie"] = csrfSetCookie;
738
+ return headers2;
739
+ };
572
740
  if (request.method === "POST" && pathname === REVALIDATE_PATH && revalidateWebhook) {
573
- const body = await readJsonBody2(request);
741
+ const body = await readJsonBody(request);
574
742
  const out2 = await revalidateWebhook({ headers: headersToObject(request.headers), body });
575
743
  return new Response(JSON.stringify(out2.body), {
576
744
  status: out2.status,
@@ -580,7 +748,7 @@ function createRequestHandler(options = {}) {
580
748
  const matched = matchRoute(pathname, routes);
581
749
  if (!matched) {
582
750
  const html = notFound ? notFound() : "<!DOCTYPE html><html><body><h1>404 \u2014 Not Found</h1></body></html>";
583
- return new Response(html, { status: 404, headers: { "content-type": "text/html; charset=utf-8" } });
751
+ return new Response(html, { status: 404, headers: withCsrfCookie({ "content-type": "text/html; charset=utf-8" }) });
584
752
  }
585
753
  const { route, params } = matched;
586
754
  const config = route.page || { mode: route.mode || "client" };
@@ -589,11 +757,12 @@ function createRequestHandler(options = {}) {
589
757
  const result = await cache.handle(routeMatch, () => renderRoute(routeMatch));
590
758
  return new Response(result.html, {
591
759
  status: result.status || 200,
592
- headers: { "content-type": "text/html; charset=utf-8", ...result.headers || {} }
760
+ headers: withCsrfCookie({ "content-type": "text/html; charset=utf-8", ...result.headers || {} })
593
761
  });
594
762
  }
763
+ if (csrfToken) routeMatch.csrfToken = csrfToken;
595
764
  const out = await renderRoute(routeMatch);
596
- const headers = { "content-type": "text/html; charset=utf-8" };
765
+ const headers = withCsrfCookie({ "content-type": "text/html; charset=utf-8" });
597
766
  if (config.mode === "server") headers["Cache-Control"] = "private, no-store";
598
767
  return new Response(out.html, { status: out.status || 200, headers });
599
768
  };
@@ -719,16 +888,51 @@ function createCloudflareHandler(options = {}) {
719
888
  function createVercelHandler(options = {}) {
720
889
  return createRequestHandler(options);
721
890
  }
722
- async function buildVercelOutput({ outDir = ".vercel/output", functionName = "render" } = {}) {
723
- const { mkdir: mkdir2, writeFile: writeFile2 } = await import("node:fs/promises");
724
- const { join: join2 } = await import("node:path");
891
+ async function buildVercelOutput({
892
+ outDir = ".vercel/output",
893
+ functionName = "render",
894
+ runtime = "nodejs22.x",
895
+ files = null,
896
+ handler = "index.mjs",
897
+ staticDir = null
898
+ } = {}) {
899
+ const { mkdir: mkdir2, writeFile: writeFile2, cp } = await import("node:fs/promises");
900
+ const { join: join2, dirname } = await import("node:path");
725
901
  await mkdir2(outDir, { recursive: true });
726
902
  const config = {
727
903
  version: 3,
728
- routes: [{ src: "/.*", dest: `/${functionName}` }]
904
+ routes: [
905
+ // CDN-served static assets win before the render function runs.
906
+ { handle: "filesystem" },
907
+ { src: "/.*", dest: `/${functionName}` }
908
+ ]
729
909
  };
730
910
  await writeFile2(join2(outDir, "config.json"), JSON.stringify(config, null, 2));
731
- return { config, outDir };
911
+ if (staticDir) {
912
+ await cp(staticDir, join2(outDir, "static"), { recursive: true });
913
+ }
914
+ let functionDir = null;
915
+ if (files && typeof files === "object") {
916
+ functionDir = join2(outDir, "functions", `${functionName}.func`);
917
+ await mkdir2(functionDir, { recursive: true });
918
+ const vcConfig = {
919
+ runtime,
920
+ handler,
921
+ launcherType: "Nodejs",
922
+ shouldAddHelpers: false,
923
+ supportsResponseStreaming: true
924
+ };
925
+ await writeFile2(join2(functionDir, ".vc-config.json"), JSON.stringify(vcConfig, null, 2));
926
+ for (const [rel, contents] of Object.entries(files)) {
927
+ const dest = join2(functionDir, rel);
928
+ await mkdir2(dirname(dest), { recursive: true });
929
+ await writeFile2(dest, contents);
930
+ }
931
+ if (!(handler in files)) {
932
+ console.warn(`[what-server] buildVercelOutput: files does not include the handler entry "${handler}" \u2014 the deploy will 500 until your build emits it.`);
933
+ }
934
+ }
935
+ return { config, outDir, functionDir };
732
936
  }
733
937
 
734
938
  // packages/server/src/index.js
@@ -891,7 +1095,8 @@ function wrapHtmlDocument({ body, head, payload, options = {} }) {
891
1095
  const lang = options.lang || "en";
892
1096
  const dataScript = `<script id="__what_data" type="application/json">${serializeState(payload)}<\/script>`;
893
1097
  const clientScript = options.clientEntry ? `<script type="module" src="${escapeHtml(options.clientEntry)}"><\/script>` : "";
894
- const extraHead = options.head || "";
1098
+ const csrfHead = options.csrfToken ? csrfMetaTag(options.csrfToken) : "";
1099
+ const extraHead = csrfHead + (options.head || "");
895
1100
  const bodyClass = options.bodyClass ? ` class="${escapeHtml(options.bodyClass)}"` : "";
896
1101
  return `<!DOCTYPE html><html lang="${escapeHtml(lang)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${head || ""}${extraHead}</head><body${bodyClass}>${body}${dataScript}${clientScript}</body></html>`;
897
1102
  }
@@ -1046,12 +1251,19 @@ function _resolveInnerHTML(props) {
1046
1251
  }
1047
1252
  return null;
1048
1253
  }
1254
+ var SAFE_ATTR_NAME = /^[a-zA-Z_:][a-zA-Z0-9:._-]*$/;
1049
1255
  function renderAttrs(props) {
1050
1256
  let out = "";
1051
1257
  for (const [key, val] of Object.entries(props)) {
1052
1258
  if (key === "key" || key === "ref" || key === "children" || key === "dangerouslySetInnerHTML" || key === "innerHTML") continue;
1053
1259
  if (key.startsWith("on") && key.length > 2) continue;
1054
1260
  if (val === false || val == null) continue;
1261
+ if (!SAFE_ATTR_NAME.test(key)) {
1262
+ if (_isDevMode) {
1263
+ console.warn(`[what-server] Skipping invalid attribute name in SSR: ${JSON.stringify(key)}`);
1264
+ }
1265
+ continue;
1266
+ }
1055
1267
  if (key === "className" || key === "class") {
1056
1268
  out += ` class="${escapeHtml(String(val))}"`;
1057
1269
  } else if (key === "style" && typeof val === "object") {