@decocms/tanstack 7.77.6 → 7.77.8
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/router.test.ts +34 -0
- package/src/sdk/router.ts +13 -1
- package/src/sdk/workerEntry.test.ts +53 -0
- package/src/sdk/workerEntry.ts +44 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.77.
|
|
3
|
+
"version": "7.77.8",
|
|
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.8",
|
|
31
|
+
"@decocms/blocks-admin": "7.77.8",
|
|
32
|
+
"@decocms/blocks-cli": "7.77.8",
|
|
33
33
|
"@deco-cx/warp-node": "^0.3.16",
|
|
34
34
|
"fast-json-patch": "^3.1.0",
|
|
35
35
|
"ws": "^8.18.0"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { decoParseSearch, decoStringifySearch } from "./router";
|
|
3
|
+
|
|
4
|
+
describe("decoStringifySearch ∘ decoParseSearch is a fixed point", () => {
|
|
5
|
+
// TanStack Router redirects (307) on the server when
|
|
6
|
+
// stringifySearch(parseSearch(search)) differs from the request's search —
|
|
7
|
+
// every query string that round-trips unchanged is a request that never
|
|
8
|
+
// redirects.
|
|
9
|
+
it.each([
|
|
10
|
+
"?q=camisa&sort=",
|
|
11
|
+
"?utm_source=",
|
|
12
|
+
"?a=&b=",
|
|
13
|
+
"?q=camisa",
|
|
14
|
+
"?filter.brand=Nike&filter.brand=Adidas",
|
|
15
|
+
// (`%20` is re-encoded as `+` by URLSearchParams — a pre-existing
|
|
16
|
+
// normalization, out of this fix's scope.)
|
|
17
|
+
"?q=a+b&page=2",
|
|
18
|
+
])("round-trips %s unchanged", (search) => {
|
|
19
|
+
expect(decoStringifySearch(decoParseSearch(search))).toBe(search);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("parses a value-less param to an empty string", () => {
|
|
23
|
+
expect(decoParseSearch("?q=camisa&sort=")).toEqual({ q: "camisa", sort: "" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("still drops undefined and null (how navigate() removes a param)", () => {
|
|
27
|
+
expect(decoStringifySearch({ q: "camisa", sort: undefined, page: null })).toBe("?q=camisa");
|
|
28
|
+
expect(decoStringifySearch({ q: undefined })).toBe("");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps an explicit empty string as key= and arrays as repeated keys", () => {
|
|
32
|
+
expect(decoStringifySearch({ sort: "", f: ["a", "b"] })).toBe("?sort=&f=a&f=b");
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/sdk/router.ts
CHANGED
|
@@ -30,10 +30,22 @@ export const decoParseSearch: SearchParser = (searchStr) => {
|
|
|
30
30
|
return result;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Serializes the router's search object back to a query string.
|
|
35
|
+
*
|
|
36
|
+
* Must be a fixed point of {@link decoParseSearch}: on the server, TanStack
|
|
37
|
+
* Router compares the request's href with `stringifySearch(parseSearch(...))`
|
|
38
|
+
* (`router.beforeLoad`) and answers a 307 to the normalized URL when they
|
|
39
|
+
* differ. A value-less parameter (`?sort=`, `?utm_source=`) parses to `""`,
|
|
40
|
+
* so dropping `""` here turned every such request into a redirect that
|
|
41
|
+
* Fresh — and TanStack's own default serializer, which only drops
|
|
42
|
+
* `undefined` — never issued. `undefined`/`null` still remove a param, which
|
|
43
|
+
* is how `navigate({ search: { q: undefined } })` clears one.
|
|
44
|
+
*/
|
|
33
45
|
export const decoStringifySearch: SearchSerializer = (search) => {
|
|
34
46
|
const params = new URLSearchParams();
|
|
35
47
|
for (const [key, value] of Object.entries(search)) {
|
|
36
|
-
if (value === undefined || value === null
|
|
48
|
+
if (value === undefined || value === null) continue;
|
|
37
49
|
if (Array.isArray(value)) {
|
|
38
50
|
for (const v of value) params.append(key, String(v));
|
|
39
51
|
} else {
|
|
@@ -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,
|
|
@@ -1082,3 +1083,55 @@ describe("Set-Cookie deduplication keys on name + Domain + Path", () => {
|
|
|
1082
1083
|
expect(out).not.toContain("checkout.vtex.com=__ofid=old; Path=/");
|
|
1083
1084
|
});
|
|
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
|
@@ -982,6 +982,46 @@ function deduplicateSetCookies(response: Response): void {
|
|
|
982
982
|
|
|
983
983
|
const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
|
|
984
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
|
+
|
|
985
1025
|
// ---------------------------------------------------------------------------
|
|
986
1026
|
// Auto geo-key detection (module-level singleton)
|
|
987
1027
|
// ---------------------------------------------------------------------------
|
|
@@ -2199,10 +2239,13 @@ export function createDecoWorkerEntry(
|
|
|
2199
2239
|
: instrumentWorker(handler, (observabilityOpt as OtelOptions | undefined) ?? {});
|
|
2200
2240
|
|
|
2201
2241
|
async function handleRequest(
|
|
2202
|
-
|
|
2242
|
+
incomingRequest: Request,
|
|
2203
2243
|
env: Record<string, unknown>,
|
|
2204
2244
|
ctx: WorkerExecutionContext,
|
|
2205
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);
|
|
2206
2249
|
const url = new URL(request.url);
|
|
2207
2250
|
|
|
2208
2251
|
// NOTE: fast-deploy hydration does NOT run here. It must happen before
|