@decocms/tanstack 7.0.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/package.json +48 -0
- package/src/cms/kvBlockSource.test.ts +67 -0
- package/src/cms/kvBlockSource.ts +54 -0
- package/src/daemon/auth.ts +204 -0
- package/src/daemon/fs.ts +238 -0
- package/src/daemon/index.ts +8 -0
- package/src/daemon/middleware.ts +156 -0
- package/src/daemon/tunnel.ts +129 -0
- package/src/daemon/volumes.ts +366 -0
- package/src/daemon/watch.ts +272 -0
- package/src/hooks/DecoPageRenderer.tsx +685 -0
- package/src/hooks/DecoRootLayout.tsx +111 -0
- package/src/hooks/NavigationProgress.tsx +21 -0
- package/src/hooks/PreviewProviders.tsx +26 -0
- package/src/hooks/StableOutlet.tsx +30 -0
- package/src/hooks/index.ts +9 -0
- package/src/index.ts +30 -0
- package/src/routes/adminRoutes.ts +114 -0
- package/src/routes/cmsRoute.ts +706 -0
- package/src/routes/components.tsx +57 -0
- package/src/routes/index.ts +24 -0
- package/src/routes/pageUrl.test.ts +70 -0
- package/src/routes/pageUrl.ts +54 -0
- package/src/routes/withSiteGlobals.test.ts +206 -0
- package/src/routes/withSiteGlobals.ts +222 -0
- package/src/sdk/cookiePassthrough.ts +58 -0
- package/src/sdk/createInvoke.ts +57 -0
- package/src/sdk/kvHydration.test.ts +171 -0
- package/src/sdk/kvHydration.ts +176 -0
- package/src/sdk/router.ts +92 -0
- package/src/sdk/useHydrated.ts +19 -0
- package/src/sdk/workerEntry.test.ts +106 -0
- package/src/sdk/workerEntry.ts +1967 -0
- package/src/setupFastDeploy.ts +13 -0
- package/src/vite/plugin.js +646 -0
- package/src/vite/plugin.test.js +113 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic bridge that turns any async function into a TanStack Start server function.
|
|
3
|
+
*
|
|
4
|
+
* Used by @decocms/apps to expose commerce actions/loaders as typed
|
|
5
|
+
* `invoke.*` calls that execute on the server with full credentials.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
|
|
10
|
+
* import { addItemsToCart } from "./actions/checkout";
|
|
11
|
+
*
|
|
12
|
+
* export const invoke = {
|
|
13
|
+
* vtex: {
|
|
14
|
+
* actions: {
|
|
15
|
+
* addItemsToCart: createInvokeFn(
|
|
16
|
+
* (input: { orderFormId: string; orderItems: CartItem[] }) =>
|
|
17
|
+
* addItemsToCart(input.orderFormId, input.orderItems),
|
|
18
|
+
* { unwrap: true },
|
|
19
|
+
* ),
|
|
20
|
+
* },
|
|
21
|
+
* },
|
|
22
|
+
* };
|
|
23
|
+
*
|
|
24
|
+
* // Client-side usage:
|
|
25
|
+
* await invoke.vtex.actions.addItemsToCart({ data: { orderFormId, orderItems } });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
29
|
+
|
|
30
|
+
export interface InvokeFnOpts {
|
|
31
|
+
/**
|
|
32
|
+
* When true, extracts `.data` from the result before returning.
|
|
33
|
+
* Use for VTEX checkout functions that return VtexFetchResult<T>
|
|
34
|
+
* (i.e. `{ data: T, setCookies: string[] }`).
|
|
35
|
+
*/
|
|
36
|
+
unwrap?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Transforms an async function into a `createServerFn` wrapper.
|
|
41
|
+
*
|
|
42
|
+
* - Client calls: `fn({ data: input })`
|
|
43
|
+
* - Server executes: `action(input)`
|
|
44
|
+
* - If `unwrap: true`, extracts `.data` from VtexFetchResult-shaped results
|
|
45
|
+
*/
|
|
46
|
+
export function createInvokeFn<TInput, TOutput>(
|
|
47
|
+
action: (input: TInput) => Promise<TOutput>,
|
|
48
|
+
opts?: InvokeFnOpts,
|
|
49
|
+
): (ctx: { data: TInput }) => Promise<TOutput> {
|
|
50
|
+
return createServerFn({ method: "POST" }).handler(async (ctx) => {
|
|
51
|
+
const result = await action(ctx.data as TInput);
|
|
52
|
+
if (opts?.unwrap && result && typeof result === "object" && "data" in result) {
|
|
53
|
+
return (result as any).data;
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}) as unknown as (ctx: { data: TInput }) => Promise<TOutput>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { computeRevision, getRevision, KV_KEYS, type KVNamespace, loadBlocks, setBlocks } from "@decocms/blocks/cms";
|
|
3
|
+
import {
|
|
4
|
+
__resetKvHydrationStateForTests,
|
|
5
|
+
ensureBlocksHydrated,
|
|
6
|
+
isFastDeployEnabled,
|
|
7
|
+
maybePollRevision,
|
|
8
|
+
} from "./kvHydration";
|
|
9
|
+
|
|
10
|
+
const BUNDLED = { Site: { name: "bundled" } };
|
|
11
|
+
|
|
12
|
+
/** KV stub that counts get() calls so we can assert throttling / single-load. */
|
|
13
|
+
function makeKV(initial: Record<string, string> = {}) {
|
|
14
|
+
const store = new Map<string, string>(Object.entries(initial));
|
|
15
|
+
let getCalls = 0;
|
|
16
|
+
const kv: KVNamespace = {
|
|
17
|
+
get: (k) => {
|
|
18
|
+
getCalls++;
|
|
19
|
+
return Promise.resolve(store.get(k) ?? null);
|
|
20
|
+
},
|
|
21
|
+
put: (k, v) => {
|
|
22
|
+
store.set(k, v);
|
|
23
|
+
return Promise.resolve();
|
|
24
|
+
},
|
|
25
|
+
delete: (k) => {
|
|
26
|
+
store.delete(k);
|
|
27
|
+
return Promise.resolve();
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return { kv, store, getCalls: () => getCalls };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function snapshotEnv(blocks: Record<string, unknown>, extra: Record<string, unknown> = {}) {
|
|
34
|
+
const { kv, ...rest } = makeKV({
|
|
35
|
+
[KV_KEYS.SNAPSHOT]: JSON.stringify(blocks),
|
|
36
|
+
[KV_KEYS.REVISION]: computeRevision(blocks),
|
|
37
|
+
});
|
|
38
|
+
// DECO_FAST_DEPLOY="1" is the explicit opt-in required alongside the binding.
|
|
39
|
+
return { env: { DECO_KV: kv, DECO_FAST_DEPLOY: "1", ...extra }, kv, ...rest };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Collects ctx.waitUntil promises so tests can await background polls. */
|
|
43
|
+
function makeCtx() {
|
|
44
|
+
const promises: Promise<unknown>[] = [];
|
|
45
|
+
return {
|
|
46
|
+
ctx: { waitUntil: (p: Promise<unknown>) => promises.push(p) },
|
|
47
|
+
settle: () => Promise.allSettled(promises),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
__resetKvHydrationStateForTests();
|
|
53
|
+
setBlocks({ ...BUNDLED }); // reset in-memory decofile to a known bundled state
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("isFastDeployEnabled", () => {
|
|
58
|
+
it("is false without a KV binding even when the flag is set", () => {
|
|
59
|
+
expect(isFastDeployEnabled({ DECO_FAST_DEPLOY: "1" })).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("is false when a non-KV value is named DECO_KV", () => {
|
|
63
|
+
expect(isFastDeployEnabled({ DECO_KV: "some-secret-string", DECO_FAST_DEPLOY: "1" })).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("is false when bound but DECO_FAST_DEPLOY is not set (explicit opt-in required)", () => {
|
|
67
|
+
const { kv } = makeKV();
|
|
68
|
+
expect(isFastDeployEnabled({ DECO_KV: kv })).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("is true when bound AND DECO_FAST_DEPLOY=1", () => {
|
|
72
|
+
const { kv } = makeKV();
|
|
73
|
+
expect(isFastDeployEnabled({ DECO_KV: kv, DECO_FAST_DEPLOY: "1" })).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("accepts DECO_FAST_DEPLOY=true as well", () => {
|
|
77
|
+
const { kv } = makeKV();
|
|
78
|
+
expect(isFastDeployEnabled({ DECO_KV: kv, DECO_FAST_DEPLOY: "true" })).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is false when DECO_FAST_DEPLOY=0 even if bound", () => {
|
|
82
|
+
const { kv } = makeKV();
|
|
83
|
+
expect(isFastDeployEnabled({ DECO_KV: kv, DECO_FAST_DEPLOY: "0" })).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("ensureBlocksHydrated", () => {
|
|
88
|
+
it("is a no-op when fast-deploy is disabled (keeps bundled blocks)", async () => {
|
|
89
|
+
await ensureBlocksHydrated({});
|
|
90
|
+
expect(loadBlocks()).toEqual(BUNDLED);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("swaps the in-memory decofile with the KV snapshot", async () => {
|
|
94
|
+
const kvBlocks = { Site: { name: "from-kv" }, "pages-home": { path: "/" } };
|
|
95
|
+
const { env } = snapshotEnv(kvBlocks);
|
|
96
|
+
await ensureBlocksHydrated(env);
|
|
97
|
+
expect(loadBlocks()).toEqual(kvBlocks);
|
|
98
|
+
expect(getRevision()).toBe(computeRevision(kvBlocks));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("loads only once even across concurrent first requests", async () => {
|
|
102
|
+
const kvBlocks = { Site: { name: "from-kv" } };
|
|
103
|
+
const { env, getCalls } = snapshotEnv(kvBlocks);
|
|
104
|
+
await Promise.all([
|
|
105
|
+
ensureBlocksHydrated(env),
|
|
106
|
+
ensureBlocksHydrated(env),
|
|
107
|
+
ensureBlocksHydrated(env),
|
|
108
|
+
]);
|
|
109
|
+
// SNAPSHOT + REVISION = 2 gets for a single load (not 6).
|
|
110
|
+
expect(getCalls()).toBe(2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps the bundled snapshot when the snapshot key is absent", async () => {
|
|
114
|
+
const { kv } = makeKV({ [KV_KEYS.REVISION]: "r" }); // no SNAPSHOT
|
|
115
|
+
await ensureBlocksHydrated({ DECO_KV: kv });
|
|
116
|
+
expect(loadBlocks()).toEqual(BUNDLED);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("falls back to bundled (and does not throw) when KV errors", async () => {
|
|
120
|
+
const kv: KVNamespace = {
|
|
121
|
+
get: () => Promise.reject(new Error("KV down")),
|
|
122
|
+
put: () => Promise.resolve(),
|
|
123
|
+
delete: () => Promise.resolve(),
|
|
124
|
+
};
|
|
125
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
126
|
+
await expect(ensureBlocksHydrated({ DECO_KV: kv })).resolves.toBeUndefined();
|
|
127
|
+
expect(loadBlocks()).toEqual(BUNDLED);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("maybePollRevision", () => {
|
|
132
|
+
it("is a no-op before cold-start hydration has run", async () => {
|
|
133
|
+
const { env, getCalls } = snapshotEnv({ Site: { name: "x" } });
|
|
134
|
+
const { ctx, settle } = makeCtx();
|
|
135
|
+
maybePollRevision(env, ctx); // kvHydrated is false
|
|
136
|
+
await settle();
|
|
137
|
+
expect(getCalls()).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("reloads the decofile when the KV revision changed", async () => {
|
|
141
|
+
const initial = { Site: { name: "v1" } };
|
|
142
|
+
const { env, store } = snapshotEnv(initial);
|
|
143
|
+
await ensureBlocksHydrated(env);
|
|
144
|
+
expect(loadBlocks()).toEqual(initial);
|
|
145
|
+
|
|
146
|
+
// Simulate a publish from another isolate: KV now holds v2.
|
|
147
|
+
const updated = { Site: { name: "v2" }, "pages-x": { path: "/x" } };
|
|
148
|
+
store.set(KV_KEYS.SNAPSHOT, JSON.stringify(updated));
|
|
149
|
+
store.set(KV_KEYS.REVISION, computeRevision(updated));
|
|
150
|
+
|
|
151
|
+
const { ctx, settle } = makeCtx();
|
|
152
|
+
maybePollRevision(env, ctx);
|
|
153
|
+
await settle();
|
|
154
|
+
expect(loadBlocks()).toEqual(updated);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("throttles to one probe per interval", async () => {
|
|
158
|
+
const { env, getCalls } = snapshotEnv({ Site: { name: "x" } });
|
|
159
|
+
await ensureBlocksHydrated(env);
|
|
160
|
+
const callsAfterHydrate = getCalls();
|
|
161
|
+
|
|
162
|
+
const { ctx, settle } = makeCtx();
|
|
163
|
+
maybePollRevision(env, ctx); // fires (revision unchanged → 1 get)
|
|
164
|
+
maybePollRevision(env, ctx); // throttled → no get
|
|
165
|
+
maybePollRevision(env, ctx); // throttled → no get
|
|
166
|
+
await settle();
|
|
167
|
+
|
|
168
|
+
// Exactly one extra getRevision() beyond the hydrate calls.
|
|
169
|
+
expect(getCalls()).toBe(callsAfterHydrate + 1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast-deploy KV hydration — the runtime read path.
|
|
3
|
+
*
|
|
4
|
+
* Bridges a Cloudflare KV namespace to the in-memory decofile so CMS content
|
|
5
|
+
* edits propagate WITHOUT a `wrangler deploy`. Two entry points, both called
|
|
6
|
+
* from `workerEntry.ts`:
|
|
7
|
+
*
|
|
8
|
+
* - `ensureBlocksHydrated(env, ctx)` — on the FIRST request per isolate, load
|
|
9
|
+
* the whole snapshot from KV and swap it in via `setBlocks()`. Awaited, so it
|
|
10
|
+
* adds one ~10-30ms cold-start hit per isolate but guarantees fresh content
|
|
11
|
+
* (the bundled `blocks.gen` snapshot is frozen at the last code deploy).
|
|
12
|
+
*
|
|
13
|
+
* - `maybePollRevision(env, ctx)` — on EVERY request, opportunistically (gated
|
|
14
|
+
* to once per `POLL_INTERVAL_MS`) probe `index:revision` via `ctx.waitUntil`
|
|
15
|
+
* so it never blocks the response; reload + swap when it changed.
|
|
16
|
+
*
|
|
17
|
+
* Everything is a no-op unless fast-deploy is enabled (`isFastDeployEnabled`),
|
|
18
|
+
* so non-migrated sites behave exactly as before.
|
|
19
|
+
*
|
|
20
|
+
* Why whole-snapshot swap (not per-block async): the resolver reads
|
|
21
|
+
* `loadBlocks()` synchronously in dozens of places. Loading the entire decofile
|
|
22
|
+
* once and swapping the map keeps that hot path synchronous — KV is touched
|
|
23
|
+
* only on cold start and during the throttled poll. Mirrors the
|
|
24
|
+
* `DecofileProvider` pattern from the deco-cx/deco Fresh runtime.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { getRevision, setBlocks } from "@decocms/blocks/cms";
|
|
28
|
+
import { KVBlockSource } from "../cms/kvBlockSource";
|
|
29
|
+
import type { KVNamespace } from "@decocms/blocks/cms";
|
|
30
|
+
import { setSpanAttribute } from "@decocms/blocks/sdk/observability";
|
|
31
|
+
|
|
32
|
+
/** How often (ms) an isolate re-probes `index:revision`. */
|
|
33
|
+
export const POLL_INTERVAL_MS = 10_000;
|
|
34
|
+
|
|
35
|
+
/** KV binding name expected on the Worker `env`. */
|
|
36
|
+
export const KV_BINDING = "DECO_KV";
|
|
37
|
+
|
|
38
|
+
/** Opt-in env var — set to "1" (or "true") to enable fast-deploy. The DECO_KV
|
|
39
|
+
* binding must also be present. */
|
|
40
|
+
export const FAST_DEPLOY_ENV = "DECO_FAST_DEPLOY";
|
|
41
|
+
|
|
42
|
+
// globalThis-backed state so all Vite server-function split-module copies share
|
|
43
|
+
// the same hydration flags (same pattern as `loader.ts`).
|
|
44
|
+
const G = globalThis as unknown as {
|
|
45
|
+
__deco?: {
|
|
46
|
+
kvHydrated?: boolean;
|
|
47
|
+
kvHydration?: Promise<void> | null;
|
|
48
|
+
kvLastPolledAt?: number;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
if (!G.__deco) G.__deco = {};
|
|
52
|
+
|
|
53
|
+
/** Minimal Cloudflare ExecutionContext shape (matches workerEntry.ts). */
|
|
54
|
+
interface ExecutionContextLike {
|
|
55
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Env = Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
function getKV(env: Env): KVNamespace | null {
|
|
61
|
+
const binding = env[KV_BINDING];
|
|
62
|
+
// Duck-type the binding: a real KVNamespace has get/put. Guards against a
|
|
63
|
+
// string/secret accidentally named DECO_KV.
|
|
64
|
+
if (binding && typeof (binding as KVNamespace).get === "function") {
|
|
65
|
+
return binding as KVNamespace;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fast-deploy is active only when BOTH hold: `DECO_FAST_DEPLOY` is set to "1"
|
|
72
|
+
* (or "true") — an explicit, per-site opt-in — AND the `DECO_KV` binding is
|
|
73
|
+
* present. Either missing ⇒ bundled-snapshot behavior, identical to
|
|
74
|
+
* pre-fast-deploy. Requiring the explicit flag means simply binding a KV
|
|
75
|
+
* namespace can't silently flip a site onto the KV read/write path.
|
|
76
|
+
*/
|
|
77
|
+
export function isFastDeployEnabled(env: Env): boolean {
|
|
78
|
+
const flag = env[FAST_DEPLOY_ENV];
|
|
79
|
+
if (flag !== "1" && flag !== "true") return false;
|
|
80
|
+
return getKV(env) !== null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the KV namespace for the write-through path, or `null` when
|
|
85
|
+
* fast-deploy is disabled. Shared by `decofile.ts` so the enablement rule
|
|
86
|
+
* lives in exactly one place.
|
|
87
|
+
*/
|
|
88
|
+
export function getFastDeployKV(env: Env): KVNamespace | null {
|
|
89
|
+
if (!isFastDeployEnabled(env)) return null;
|
|
90
|
+
return getKV(env);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Cold-start hydration. Awaits the KV snapshot once per isolate and swaps it
|
|
95
|
+
* into the in-memory block map. Concurrent first requests share a single
|
|
96
|
+
* in-flight load. On any error (KV outage, bad JSON) we keep the bundled
|
|
97
|
+
* snapshot and mark hydration done — recovery happens via `maybePollRevision`
|
|
98
|
+
* once KV is reachable again (its revision will differ from the bundled one).
|
|
99
|
+
*/
|
|
100
|
+
export function ensureBlocksHydrated(env: Env, _ctx?: ExecutionContextLike): Promise<void> {
|
|
101
|
+
if (!isFastDeployEnabled(env)) return Promise.resolve();
|
|
102
|
+
if (G.__deco!.kvHydrated) return Promise.resolve();
|
|
103
|
+
if (G.__deco!.kvHydration) return G.__deco!.kvHydration;
|
|
104
|
+
|
|
105
|
+
const kv = getKV(env);
|
|
106
|
+
if (!kv) return Promise.resolve();
|
|
107
|
+
|
|
108
|
+
const load = (async () => {
|
|
109
|
+
try {
|
|
110
|
+
const snapshot = await new KVBlockSource(kv).loadSnapshot();
|
|
111
|
+
if (snapshot) {
|
|
112
|
+
setBlocks(snapshot.blocks);
|
|
113
|
+
setSpanAttribute("deco.block.source", "kv");
|
|
114
|
+
} else {
|
|
115
|
+
setSpanAttribute("deco.block.source", "bundled");
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
// Non-fatal: serve the bundled snapshot. The poll loop recovers later.
|
|
119
|
+
console.warn("[CMS/KV] cold-start hydration failed, using bundled snapshot:", e);
|
|
120
|
+
setSpanAttribute("deco.block.source", "bundled");
|
|
121
|
+
} finally {
|
|
122
|
+
G.__deco!.kvHydrated = true;
|
|
123
|
+
G.__deco!.kvHydration = null;
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
|
|
127
|
+
G.__deco!.kvHydration = load;
|
|
128
|
+
return load;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Opportunistic revision poll. Throttled to once per `POLL_INTERVAL_MS` and run
|
|
133
|
+
* through `ctx.waitUntil` so it never adds latency to the response. Reloads the
|
|
134
|
+
* snapshot and swaps it in when KV's revision differs from the in-memory one.
|
|
135
|
+
*/
|
|
136
|
+
export function maybePollRevision(env: Env, ctx?: ExecutionContextLike): void {
|
|
137
|
+
if (!isFastDeployEnabled(env)) return;
|
|
138
|
+
if (!G.__deco!.kvHydrated) return; // wait until cold-start hydration finished
|
|
139
|
+
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
if (now - (G.__deco!.kvLastPolledAt ?? 0) < POLL_INTERVAL_MS) return;
|
|
142
|
+
G.__deco!.kvLastPolledAt = now;
|
|
143
|
+
|
|
144
|
+
const kv = getKV(env);
|
|
145
|
+
if (!kv) return;
|
|
146
|
+
|
|
147
|
+
const poll = pollRevisionOnce(kv);
|
|
148
|
+
// Prefer waitUntil so the work outlives the response; fall back to a
|
|
149
|
+
// fire-and-forget promise (dev / tests) with its rejection swallowed.
|
|
150
|
+
if (ctx?.waitUntil) ctx.waitUntil(poll);
|
|
151
|
+
else void poll.catch(() => {});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function pollRevisionOnce(kv: KVNamespace): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
const source = new KVBlockSource(kv);
|
|
157
|
+
const remoteRevision = await source.getRevision();
|
|
158
|
+
if (!remoteRevision || remoteRevision === getRevision()) return;
|
|
159
|
+
|
|
160
|
+
const snapshot = await source.loadSnapshot();
|
|
161
|
+
if (snapshot) {
|
|
162
|
+
setBlocks(snapshot.blocks);
|
|
163
|
+
console.info(`[CMS/KV] decofile refreshed → revision ${snapshot.revision}`);
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
// Swallow — a failed poll must never affect the request. Next tick retries.
|
|
167
|
+
console.warn("[CMS/KV] revision poll failed:", e);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Test-only: reset the isolate-level hydration flags. */
|
|
172
|
+
export function __resetKvHydrationStateForTests(): void {
|
|
173
|
+
G.__deco!.kvHydrated = false;
|
|
174
|
+
G.__deco!.kvHydration = null;
|
|
175
|
+
G.__deco!.kvLastPolledAt = 0;
|
|
176
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deco-flavored TanStack Router factory.
|
|
3
|
+
*
|
|
4
|
+
* Uses standard URLSearchParams serialization instead of TanStack's default
|
|
5
|
+
* JSON-based format. Required because VTEX (and most commerce platforms) uses
|
|
6
|
+
* filter URLs like `?filter.brand=Nike&filter.brand=Adidas` which must
|
|
7
|
+
* round-trip correctly through the router's search system.
|
|
8
|
+
*/
|
|
9
|
+
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
|
10
|
+
import type {
|
|
11
|
+
SearchSerializer,
|
|
12
|
+
SearchParser,
|
|
13
|
+
AnyRoute,
|
|
14
|
+
TrailingSlashOption,
|
|
15
|
+
} from "@tanstack/react-router";
|
|
16
|
+
|
|
17
|
+
export const decoParseSearch: SearchParser = (searchStr) => {
|
|
18
|
+
const str = searchStr.startsWith("?") ? searchStr.slice(1) : searchStr;
|
|
19
|
+
if (!str) return {};
|
|
20
|
+
|
|
21
|
+
const params = new URLSearchParams(str);
|
|
22
|
+
const result: Record<string, string | string[]> = {};
|
|
23
|
+
|
|
24
|
+
for (const key of new Set(params.keys())) {
|
|
25
|
+
const values = params.getAll(key);
|
|
26
|
+
result[key] = values.length === 1 ? values[0] : values;
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const decoStringifySearch: SearchSerializer = (search) => {
|
|
32
|
+
const params = new URLSearchParams();
|
|
33
|
+
for (const [key, value] of Object.entries(search)) {
|
|
34
|
+
if (value === undefined || value === null || value === "") continue;
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
for (const v of value) params.append(key, String(v));
|
|
37
|
+
} else {
|
|
38
|
+
params.append(key, String(value));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const str = params.toString();
|
|
42
|
+
return str ? `?${str}` : "";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface CreateDecoRouterOptions {
|
|
46
|
+
routeTree: AnyRoute;
|
|
47
|
+
scrollRestoration?: boolean;
|
|
48
|
+
defaultPreload?: "intent" | "viewport" | "render" | false;
|
|
49
|
+
trailingSlash?: TrailingSlashOption;
|
|
50
|
+
/**
|
|
51
|
+
* Router context — passed to all route loaders/components via routeContext.
|
|
52
|
+
* Commonly used for { queryClient } per TanStack Query integration docs.
|
|
53
|
+
*/
|
|
54
|
+
context?: Record<string, unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* Non-DOM provider component to wrap the entire router.
|
|
57
|
+
* Per TanStack docs, only non-DOM-rendering components (providers) should
|
|
58
|
+
* be used — anything else causes hydration errors.
|
|
59
|
+
*
|
|
60
|
+
* Example: QueryClientProvider wrapping
|
|
61
|
+
* Wrap: ({ children }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
62
|
+
*/
|
|
63
|
+
Wrap?: (props: { children: any }) => any;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a TanStack Router with Deco defaults:
|
|
68
|
+
* - URLSearchParams-based search serialization (not JSON)
|
|
69
|
+
* - Scroll restoration enabled
|
|
70
|
+
* - Preload on intent
|
|
71
|
+
*/
|
|
72
|
+
export function createDecoRouter(options: CreateDecoRouterOptions) {
|
|
73
|
+
const {
|
|
74
|
+
routeTree,
|
|
75
|
+
scrollRestoration = true,
|
|
76
|
+
defaultPreload = "intent",
|
|
77
|
+
trailingSlash,
|
|
78
|
+
context,
|
|
79
|
+
Wrap,
|
|
80
|
+
} = options;
|
|
81
|
+
|
|
82
|
+
return createTanStackRouter({
|
|
83
|
+
routeTree,
|
|
84
|
+
scrollRestoration,
|
|
85
|
+
defaultPreload,
|
|
86
|
+
trailingSlash,
|
|
87
|
+
context: context as any,
|
|
88
|
+
Wrap,
|
|
89
|
+
parseSearch: decoParseSearch,
|
|
90
|
+
stringifySearch: decoStringifySearch,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export of TanStack Router's `useHydrated` hook.
|
|
3
|
+
*
|
|
4
|
+
* Returns `false` during SSR and on the first client render (before hydration),
|
|
5
|
+
* then `true` for all subsequent renders. Use this instead of
|
|
6
|
+
* `typeof document === "undefined"` checks for conditional rendering.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { useHydrated } from "@decocms/start/sdk/useHydrated";
|
|
11
|
+
*
|
|
12
|
+
* function CartButton() {
|
|
13
|
+
* const hydrated = useHydrated();
|
|
14
|
+
* if (!hydrated) return <CartSkeleton />;
|
|
15
|
+
* return <InteractiveCart />;
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export { useHydrated } from "@tanstack/react-router";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { injectGeoCookies } from "./workerEntry";
|
|
3
|
+
|
|
4
|
+
function parseCookies(header: string): Record<string, string> {
|
|
5
|
+
return Object.fromEntries(
|
|
6
|
+
header.split("; ").map((c) => {
|
|
7
|
+
const [k, ...v] = c.split("=");
|
|
8
|
+
return [k, v.join("=")];
|
|
9
|
+
}),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeRequest(
|
|
14
|
+
cf: Record<string, string> | undefined,
|
|
15
|
+
headers: Record<string, string> = {},
|
|
16
|
+
): Request {
|
|
17
|
+
const req = new Request("https://example.com/", { headers });
|
|
18
|
+
if (cf) {
|
|
19
|
+
Object.defineProperty(req, "cf", { value: cf, configurable: true });
|
|
20
|
+
}
|
|
21
|
+
return req;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("injectGeoCookies", () => {
|
|
25
|
+
it("strips cf-region from the outgoing Request headers while preserving the value in __cf_geo_region cookie", () => {
|
|
26
|
+
const req = makeRequest(
|
|
27
|
+
{ region: "São Paulo", country: "BR" },
|
|
28
|
+
{ "cf-region": "São Paulo", "cf-ipcountry": "BR" },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const out = injectGeoCookies(req);
|
|
32
|
+
|
|
33
|
+
expect(out.headers.get("cf-region")).toBeNull();
|
|
34
|
+
// ASCII CF headers (cf-ipcountry) are still forwarded
|
|
35
|
+
expect(out.headers.get("cf-ipcountry")).toBe("BR");
|
|
36
|
+
// Geo data is preserved as cookies for matchers
|
|
37
|
+
const cookies = parseCookies(out.headers.get("cookie") ?? "");
|
|
38
|
+
expect(cookies.__cf_geo_region).toBe(encodeURIComponent("São Paulo"));
|
|
39
|
+
expect(cookies.__cf_geo_country).toBe("BR");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("strips cf-ipcity from the outgoing Request headers while preserving the value in __cf_geo_city cookie", () => {
|
|
43
|
+
const req = makeRequest(
|
|
44
|
+
{ city: "Brasília", country: "BR" },
|
|
45
|
+
{ "cf-ipcity": "Brasília" },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const out = injectGeoCookies(req);
|
|
49
|
+
|
|
50
|
+
expect(out.headers.get("cf-ipcity")).toBeNull();
|
|
51
|
+
const cookies = parseCookies(out.headers.get("cookie") ?? "");
|
|
52
|
+
expect(cookies.__cf_geo_city).toBe(encodeURIComponent("Brasília"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns the original request unchanged when there is no cf object", () => {
|
|
56
|
+
const req = makeRequest(undefined, { "cf-region": "São Paulo" });
|
|
57
|
+
|
|
58
|
+
const out = injectGeoCookies(req);
|
|
59
|
+
|
|
60
|
+
// Without cf, we don't build cookies, and we return the original request
|
|
61
|
+
// untouched (so the cf-region header is still present — but that's the
|
|
62
|
+
// caller's pre-existing state, not something we re-introduced).
|
|
63
|
+
expect(out).toBe(req);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns the original request unchanged when cf has no relevant geo fields", () => {
|
|
67
|
+
const req = makeRequest({ asn: "12345" }, { "cf-region": "São Paulo" });
|
|
68
|
+
|
|
69
|
+
const out = injectGeoCookies(req);
|
|
70
|
+
|
|
71
|
+
expect(out).toBe(req);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("preserves a pre-existing cookie header", () => {
|
|
75
|
+
const req = makeRequest(
|
|
76
|
+
{ region: "São Paulo" },
|
|
77
|
+
{ cookie: "vtex_segment=abc; another=xyz" },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const out = injectGeoCookies(req);
|
|
81
|
+
|
|
82
|
+
const raw = out.headers.get("cookie") ?? "";
|
|
83
|
+
expect(raw).toContain("vtex_segment=abc");
|
|
84
|
+
expect(raw).toContain("another=xyz");
|
|
85
|
+
expect(raw).toContain("__cf_geo_region=");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("forwards non-geo headers untouched", () => {
|
|
89
|
+
const req = makeRequest(
|
|
90
|
+
{ region: "Paraná" },
|
|
91
|
+
{
|
|
92
|
+
"user-agent": "test-agent",
|
|
93
|
+
accept: "*/*",
|
|
94
|
+
"x-custom": "value",
|
|
95
|
+
"cf-ray": "9ff5b26cf9bc067a",
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const out = injectGeoCookies(req);
|
|
100
|
+
|
|
101
|
+
expect(out.headers.get("user-agent")).toBe("test-agent");
|
|
102
|
+
expect(out.headers.get("accept")).toBe("*/*");
|
|
103
|
+
expect(out.headers.get("x-custom")).toBe("value");
|
|
104
|
+
expect(out.headers.get("cf-ray")).toBe("9ff5b26cf9bc067a");
|
|
105
|
+
});
|
|
106
|
+
});
|