@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.77.6",
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.6",
31
- "@decocms/blocks-admin": "7.77.6",
32
- "@decocms/blocks-cli": "7.77.6",
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 || value === "") continue;
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
+ });
@@ -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
- request: Request,
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