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/README.md +148 -0
- package/dist/actions.js +1 -1
- package/dist/actions.js.map +1 -1
- package/dist/actions.min.js +1 -1
- package/dist/actions.min.js.map +2 -2
- package/dist/index.js +246 -34
- package/dist/index.js.map +3 -3
- package/dist/index.min.js +5 -5
- package/dist/index.min.js.map +3 -3
- package/package.json +3 -3
- package/src/action-handler.js +203 -21
- package/src/adapter/cloudflare.js +1 -1
- package/src/adapter/core.js +102 -11
- package/src/adapter/vercel.js +74 -10
- package/src/index.js +21 -2
- package/src/revalidation-registry.js +2 -2
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-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">")}</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
|
|
438
|
-
|
|
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
|
-
|
|
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("?")
|
|
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
|
-
|
|
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
|
|
535
|
+
res.end(JSON.stringify({ message: err.code === "BODY_TOO_LARGE" ? "Payload too large" : "Invalid request body" }));
|
|
467
536
|
return;
|
|
468
537
|
}
|
|
469
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
569
|
-
|
|
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
|
|
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({
|
|
723
|
-
|
|
724
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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") {
|