@decocms/tanstack 7.77.5 → 7.77.7
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/package.json +4 -4
- package/src/sdk/workerEntry.test.ts +104 -0
- package/src/sdk/workerEntry.ts +76 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.77.
|
|
3
|
+
"version": "7.77.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"lint:unused": "knip"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@decocms/blocks": "7.77.
|
|
31
|
-
"@decocms/blocks-admin": "7.77.
|
|
32
|
-
"@decocms/blocks-cli": "7.77.
|
|
30
|
+
"@decocms/blocks": "7.77.7",
|
|
31
|
+
"@decocms/blocks-admin": "7.77.7",
|
|
32
|
+
"@decocms/blocks-cli": "7.77.7",
|
|
33
33
|
"@deco-cx/warp-node": "^0.3.16",
|
|
34
34
|
"fast-json-patch": "^3.1.0",
|
|
35
35
|
"ws": "^8.18.0"
|
|
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
7
7
|
import { segmentToken } from "./cdnSegment";
|
|
8
8
|
import { __resetKvHydrationStateForTests } from "./kvHydration";
|
|
9
9
|
import {
|
|
10
|
+
aliasLiveInvoke,
|
|
10
11
|
buildEnforcedCsp,
|
|
11
12
|
buildGeoCacheParam,
|
|
12
13
|
createDecoWorkerEntry,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
DEFAULT_SECURITY_HEADERS,
|
|
16
17
|
detectLocationMatcher,
|
|
17
18
|
injectGeoCookies,
|
|
19
|
+
setCookieIdentity,
|
|
18
20
|
} from "./workerEntry";
|
|
19
21
|
|
|
20
22
|
const EMPTY_ENV = {};
|
|
@@ -1031,3 +1033,105 @@ describe("fast-deploy hydration ordering", () => {
|
|
|
1031
1033
|
}
|
|
1032
1034
|
});
|
|
1033
1035
|
});
|
|
1036
|
+
|
|
1037
|
+
describe("Set-Cookie deduplication keys on name + Domain + Path", () => {
|
|
1038
|
+
it("setCookieIdentity: same name, different scope → different identities", () => {
|
|
1039
|
+
expect(setCookieIdentity("a=1; Path=/")).not.toBe(
|
|
1040
|
+
setCookieIdentity("a=1; Path=/; Domain=example.com"),
|
|
1041
|
+
);
|
|
1042
|
+
expect(setCookieIdentity("a=1; Path=/; Domain=www.example.com")).not.toBe(
|
|
1043
|
+
setCookieIdentity("a=1; Path=/; Domain=example.com"),
|
|
1044
|
+
);
|
|
1045
|
+
expect(setCookieIdentity("a=1; Path=/")).not.toBe(setCookieIdentity("a=1; Path=/account"));
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it("setCookieIdentity: Domain is case-insensitive, a leading dot is ignored, value is not part of the key", () => {
|
|
1049
|
+
expect(setCookieIdentity("a=1; Path=/; Domain=Example.COM")).toBe(
|
|
1050
|
+
setCookieIdentity("a=2; path=/; domain=.example.com; Secure"),
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("keeps every scope of a multi-scope clear (sign-out) and still collapses true duplicates", async () => {
|
|
1055
|
+
setBlocks({});
|
|
1056
|
+
const clears = [
|
|
1057
|
+
"VtexIdclientAutCookie=; Path=/; Max-Age=0",
|
|
1058
|
+
"VtexIdclientAutCookie=; Path=/; Max-Age=0; Domain=www.example.com",
|
|
1059
|
+
"VtexIdclientAutCookie=; Path=/; Max-Age=0; Domain=example.com",
|
|
1060
|
+
];
|
|
1061
|
+
const entry = {
|
|
1062
|
+
fetch: async () => {
|
|
1063
|
+
const headers = new Headers({ "content-type": "text/plain" });
|
|
1064
|
+
// A true duplicate (same name, same scope): only the last one survives.
|
|
1065
|
+
headers.append("set-cookie", "checkout.vtex.com=__ofid=old; Path=/");
|
|
1066
|
+
for (const sc of clears) headers.append("set-cookie", sc);
|
|
1067
|
+
headers.append("set-cookie", "checkout.vtex.com=__ofid=new; Path=/");
|
|
1068
|
+
return new Response("ok", { status: 200, headers });
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
const worker = createDecoWorkerEntry(entry, { observability: false });
|
|
1072
|
+
const res = await worker.fetch(
|
|
1073
|
+
new Request("https://www.example.com/telesales/sign-out"),
|
|
1074
|
+
EMPTY_ENV,
|
|
1075
|
+
MOCK_CTX,
|
|
1076
|
+
);
|
|
1077
|
+
const out = (res.headers as unknown as { getSetCookie: () => string[] }).getSetCookie();
|
|
1078
|
+
// Order is not part of the contract; the set of surviving headers is.
|
|
1079
|
+
expect(out).toHaveLength(4);
|
|
1080
|
+
expect(out).toEqual(
|
|
1081
|
+
expect.arrayContaining([...clears, "checkout.vtex.com=__ofid=new; Path=/"]),
|
|
1082
|
+
);
|
|
1083
|
+
expect(out).not.toContain("checkout.vtex.com=__ofid=old; Path=/");
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
describe("/live/invoke alias (Fresh compat)", () => {
|
|
1088
|
+
it("leaves every other path untouched (same Request reference)", () => {
|
|
1089
|
+
for (const path of ["/", "/deco/invoke/site/loaders/x.ts", "/live/_meta", "/live/invoke-not"]) {
|
|
1090
|
+
const req = new Request(`https://example.com${path}`);
|
|
1091
|
+
expect(aliasLiveInvoke(req)).toBe(req);
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it("rewrites /live/invoke and /live/invoke/* to /deco/invoke[/*], keeping query, method, headers, body and cf", async () => {
|
|
1096
|
+
const req = new Request("https://example.com/live/invoke/site/loaders/x.ts?select=a,b", {
|
|
1097
|
+
method: "POST",
|
|
1098
|
+
headers: { "content-type": "application/json", "x-custom": "1" },
|
|
1099
|
+
body: JSON.stringify({ id: 1 }),
|
|
1100
|
+
});
|
|
1101
|
+
Object.defineProperty(req, "cf", { value: { colo: "GRU" }, configurable: true });
|
|
1102
|
+
|
|
1103
|
+
const out = aliasLiveInvoke(req);
|
|
1104
|
+
expect(out.url).toBe("https://example.com/deco/invoke/site/loaders/x.ts?select=a,b");
|
|
1105
|
+
expect(out.method).toBe("POST");
|
|
1106
|
+
expect(out.headers.get("x-custom")).toBe("1");
|
|
1107
|
+
expect(await out.json()).toEqual({ id: 1 });
|
|
1108
|
+
expect((out as unknown as { cf?: unknown }).cf).toEqual({ colo: "GRU" });
|
|
1109
|
+
|
|
1110
|
+
expect(aliasLiveInvoke(new Request("https://example.com/live/invoke")).url).toBe(
|
|
1111
|
+
"https://example.com/deco/invoke",
|
|
1112
|
+
);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it("end-to-end: the server entry receives the /deco/invoke URL", async () => {
|
|
1116
|
+
setBlocks({});
|
|
1117
|
+
const urls: string[] = [];
|
|
1118
|
+
const entry = {
|
|
1119
|
+
fetch: async (req: Request) => {
|
|
1120
|
+
urls.push(req.url);
|
|
1121
|
+
return Response.json({ ok: true });
|
|
1122
|
+
},
|
|
1123
|
+
};
|
|
1124
|
+
const worker = createDecoWorkerEntry(entry, { observability: false });
|
|
1125
|
+
const res = await worker.fetch(
|
|
1126
|
+
new Request("https://example.com/live/invoke/site/loaders/x.ts", {
|
|
1127
|
+
method: "POST",
|
|
1128
|
+
headers: { "content-type": "application/json" },
|
|
1129
|
+
body: "{}",
|
|
1130
|
+
}),
|
|
1131
|
+
EMPTY_ENV,
|
|
1132
|
+
MOCK_CTX,
|
|
1133
|
+
);
|
|
1134
|
+
expect(res.status).toBe(200);
|
|
1135
|
+
expect(urls).toEqual(["https://example.com/deco/invoke/site/loaders/x.ts"]);
|
|
1136
|
+
});
|
|
1137
|
+
});
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -930,20 +930,45 @@ function stripSafeCookiesForCache(response: Response, safeCookieSet: Set<string>
|
|
|
930
930
|
}
|
|
931
931
|
|
|
932
932
|
/**
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
933
|
+
* Identity of a `Set-Cookie` header for deduplication: the browser keys its
|
|
934
|
+
* cookie jar by name + Domain + Path (RFC 6265 §5.3 step 11), so two headers
|
|
935
|
+
* that differ in scope address two different cookies — a clear for the
|
|
936
|
+
* host-only cookie does not touch the `Domain=example.com` one. The domain is
|
|
937
|
+
* case-insensitive and a leading dot is ignored (§5.2.3); the path is
|
|
938
|
+
* compared as sent.
|
|
939
|
+
*/
|
|
940
|
+
export function setCookieIdentity(setCookie: string): string {
|
|
941
|
+
const [nameValue = "", ...attrs] = setCookie.split(";");
|
|
942
|
+
const eqIdx = nameValue.indexOf("=");
|
|
943
|
+
const name = eqIdx > 0 ? nameValue.slice(0, eqIdx).trim() : nameValue.trim();
|
|
944
|
+
let domain = "";
|
|
945
|
+
let path = "";
|
|
946
|
+
for (const attr of attrs) {
|
|
947
|
+
const sep = attr.indexOf("=");
|
|
948
|
+
const key = (sep >= 0 ? attr.slice(0, sep) : attr).trim().toLowerCase();
|
|
949
|
+
const value = sep >= 0 ? attr.slice(sep + 1).trim() : "";
|
|
950
|
+
if (key === "domain") domain = value.replace(/^\./, "").toLowerCase();
|
|
951
|
+
else if (key === "path") path = value;
|
|
952
|
+
}
|
|
953
|
+
return `${name}\u0000${domain}\u0000${path}`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Deduplicate Set-Cookie headers — keep only the LAST occurrence of each
|
|
958
|
+
* cookie identity (name + Domain + Path, see {@link setCookieIdentity}).
|
|
959
|
+
* Multiple layers (VTEX middleware, invoke handlers, etc.) may independently
|
|
960
|
+
* append the same cookie. Keying by name alone collapsed deliberate clears of
|
|
961
|
+
* the same cookie under different scopes (host-only / `Domain=host` /
|
|
962
|
+
* `Domain=parent`) into whichever came last, leaving the other scopes alive.
|
|
936
963
|
*/
|
|
937
964
|
function deduplicateSetCookies(response: Response): void {
|
|
938
965
|
const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
|
|
939
966
|
if (!setCookies || setCookies.length <= 1) return;
|
|
940
967
|
|
|
941
|
-
// Build map: cookie
|
|
968
|
+
// Build map: cookie identity → last Set-Cookie value
|
|
942
969
|
const seen = new Map<string, string>();
|
|
943
970
|
for (const sc of setCookies) {
|
|
944
|
-
|
|
945
|
-
const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : sc;
|
|
946
|
-
seen.set(name, sc);
|
|
971
|
+
seen.set(setCookieIdentity(sc), sc);
|
|
947
972
|
}
|
|
948
973
|
|
|
949
974
|
// If no duplicates, nothing to do
|
|
@@ -957,6 +982,46 @@ function deduplicateSetCookies(response: Response): void {
|
|
|
957
982
|
|
|
958
983
|
const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
|
|
959
984
|
|
|
985
|
+
const LIVE_INVOKE_PREFIX = "/live/invoke";
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Fresh-compat alias: serve `/live/invoke` and `/live/invoke/*` as
|
|
989
|
+
* `/deco/invoke[/*]`. deco-runtime registered both prefixes for the same
|
|
990
|
+
* handler (`runtime/handler.tsx`: `paths: ["/live/invoke", "/deco/invoke"]`),
|
|
991
|
+
* and clients built against a Fresh site — the mobile app, partner
|
|
992
|
+
* integrations — still call `/live/invoke/...`. Here that path fell through
|
|
993
|
+
* to the CMS router and answered a 404 HTML page.
|
|
994
|
+
*
|
|
995
|
+
* Returns the same Request when the path is not a `/live/invoke` one, so the
|
|
996
|
+
* common path allocates nothing. The rewritten Request keeps method, headers,
|
|
997
|
+
* body and `cf` (the `new Request(url, request)` form does not reliably carry
|
|
998
|
+
* `cf` across runtimes — see `revalidationRequest`).
|
|
999
|
+
*/
|
|
1000
|
+
export function aliasLiveInvoke(request: Request): Request {
|
|
1001
|
+
const url = new URL(request.url);
|
|
1002
|
+
const { pathname } = url;
|
|
1003
|
+
if (pathname !== LIVE_INVOKE_PREFIX && !pathname.startsWith(`${LIVE_INVOKE_PREFIX}/`)) {
|
|
1004
|
+
return request;
|
|
1005
|
+
}
|
|
1006
|
+
url.pathname = `/deco/invoke${pathname.slice(LIVE_INVOKE_PREFIX.length)}`;
|
|
1007
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
1008
|
+
const cf = (request as unknown as { cf?: unknown }).cf;
|
|
1009
|
+
const aliased = new Request(url.toString(), {
|
|
1010
|
+
method: request.method,
|
|
1011
|
+
headers: request.headers,
|
|
1012
|
+
redirect: request.redirect,
|
|
1013
|
+
signal: request.signal,
|
|
1014
|
+
...(hasBody ? { body: request.body, duplex: "half" } : {}),
|
|
1015
|
+
...(cf ? { cf } : {}),
|
|
1016
|
+
} as RequestInit);
|
|
1017
|
+
// Runtimes other than workerd ignore the `cf` init field; re-attach it so a
|
|
1018
|
+
// location matcher downstream never reads a dropped `cf`.
|
|
1019
|
+
if (cf !== undefined && (aliased as unknown as { cf?: unknown }).cf === undefined) {
|
|
1020
|
+
Object.defineProperty(aliased, "cf", { value: cf, configurable: true });
|
|
1021
|
+
}
|
|
1022
|
+
return aliased;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
960
1025
|
// ---------------------------------------------------------------------------
|
|
961
1026
|
// Auto geo-key detection (module-level singleton)
|
|
962
1027
|
// ---------------------------------------------------------------------------
|
|
@@ -2174,10 +2239,13 @@ export function createDecoWorkerEntry(
|
|
|
2174
2239
|
: instrumentWorker(handler, (observabilityOpt as OtelOptions | undefined) ?? {});
|
|
2175
2240
|
|
|
2176
2241
|
async function handleRequest(
|
|
2177
|
-
|
|
2242
|
+
incomingRequest: Request,
|
|
2178
2243
|
env: Record<string, unknown>,
|
|
2179
2244
|
ctx: WorkerExecutionContext,
|
|
2180
2245
|
): Promise<Response> {
|
|
2246
|
+
// Fresh-compat `/live/invoke` → `/deco/invoke`, before anything reads
|
|
2247
|
+
// the path (admin routes, bypass paths, the site's `/deco/invoke/$` route).
|
|
2248
|
+
const request = aliasLiveInvoke(incomingRequest);
|
|
2181
2249
|
const url = new URL(request.url);
|
|
2182
2250
|
|
|
2183
2251
|
// NOTE: fast-deploy hydration does NOT run here. It must happen before
|