Skip to content

Commit 65a4e88

Browse files
committed
chore: Replace KV config cache with native fetch cache and clarify optional persistence
Address senior developer feedback on the Cloudflare Workers integration: - Replace KV-based EdgeConfigCache with Cloudflare built-in fetch cache (cf: { cacheTtl, cacheEverything }) — simpler, free on all plans, no KV dependency needed for basic setup - Mark KVDataStore as optional — deterministic MurmurHash bucketing means the same visitor ID always gets the same variation without persistence - Document that releaseQueues() must be called in waitUntil() before the Worker finishes, since the SDK setTimeout-based event timer will not fire in stateless Workers - Update demo, README, and wrangler.toml to reflect KV-free default setup
1 parent 580e06e commit 65a4e88

5 files changed

Lines changed: 103 additions & 109 deletions

File tree

demo/cloudflare-workers/src/index.ts

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
import ConvertSDK, {BucketedVariation} from '@convertcom/js-sdk';
1919
import {
20-
KVDataStore,
2120
EdgeConfigCache,
2221
getVisitorId,
2322
setVisitorIdCookie,
@@ -31,33 +30,35 @@ import {
3130
// ---------------------------------------------------------------------------
3231

3332
interface Env {
34-
CONVERT_KV: KVNamespace;
3533
CONVERT_SDK_KEY: string;
34+
// KV is optional — only needed if you enable the KVDataStore for
35+
// persisting visitor bucketing data across experience config changes.
36+
// CONVERT_KV: KVNamespace;
3637
}
3738

3839
// ---------------------------------------------------------------------------
3940
// SDK Singleton
4041
// ---------------------------------------------------------------------------
4142

4243
// The SDK instance persists across requests within the same Worker isolate.
43-
// Config is loaded from KV on the first request and reused afterwards.
44+
// Config is fetched from the Convert CDN and cached at the Cloudflare edge
45+
// using the native `cf` fetch cache (no KV required).
4446
// The initialization promise is cached to prevent race conditions when
4547
// concurrent requests hit a cold Worker simultaneously.
4648
let sdk: InstanceType<typeof ConvertSDK> | null = null;
4749
let sdkReadyPromise: Promise<InstanceType<typeof ConvertSDK>> | null = null;
4850

4951
/**
5052
* Initialise (or reuse) the SDK singleton.
51-
* Uses EdgeConfigCache so the config is served from KV (~1 ms) instead
52-
* of fetching from the CDN (~100 ms) on every cold start.
53+
* Uses EdgeConfigCache with Cloudflare's built-in fetch cache — the config
54+
* response is cached at the edge for the TTL duration, no KV needed.
5355
*/
5456
async function getSDK(env: Env): Promise<InstanceType<typeof ConvertSDK>> {
5557
if (sdk) return sdk;
5658
if (sdkReadyPromise) return sdkReadyPromise;
5759

5860
sdkReadyPromise = (async () => {
5961
const configCache = new EdgeConfigCache(
60-
env.CONVERT_KV,
6162
env.CONVERT_SDK_KEY,
6263
300 // cache TTL in seconds (5 minutes)
6364
);
@@ -110,17 +111,15 @@ export default {
110111
visitorId = generateVisitorId();
111112
}
112113

113-
// 3. Load persisted bucketing data from KV
114-
const dataStore = new KVDataStore(env.CONVERT_KV);
115-
await dataStore.load(visitorId);
116-
117-
// 4. Create visitor context
114+
// 3. Create visitor context
115+
// No KV persistence needed — the SDK uses deterministic MurmurHash
116+
// bucketing, so the same visitorId always gets the same variation.
118117
const context = convert.createContext(visitorId);
119118
if (!context) {
120119
return fetch(request);
121120
}
122121

123-
// 5. Run experiments
122+
// 4. Run experiments
124123
// Replace the experience key with your actual experience key from Convert.
125124
const variation = context.runExperience('your-experience-key', {
126125
locationProperties: {url: url.pathname}
@@ -131,27 +130,30 @@ export default {
131130
return fetch(request);
132131
}
133132

134-
// 6. Fetch the origin page
133+
// 5. Fetch the origin page
135134
const originResponse = await fetch(request);
136135

137-
// 7. Apply the variation using HTMLRewriter
136+
// 6. Apply the variation using HTMLRewriter
138137
const modifiedResponse = applyVariation(originResponse, variation);
139138

140-
// 8. Build response headers (visitor cookie + cache control)
139+
// 7. Build response headers (visitor cookie + cache control)
141140
const headers = new Headers(modifiedResponse.headers);
142141
if (isNewVisitor) {
143142
setVisitorIdCookie(headers, visitorId);
144143
}
145144
setCacheHeaders(headers, 300);
146145

147-
// 9. Persist bucketing data and release tracking events in the background
148-
// waitUntil() ensures these complete even after the response is sent.
149-
ctx.waitUntil(
150-
Promise.all([
151-
dataStore.save(visitorId),
152-
context.releaseQueues('edge-request-complete')
153-
])
154-
);
146+
// 8. Release tracking events in the background
147+
//
148+
// IMPORTANT: The SDK batches tracking events and releases them on a
149+
// timer (setTimeout). In Cloudflare Workers, the isolate may finish
150+
// before that timer fires, so events would be lost. You MUST call
151+
// releaseQueues() explicitly to flush all pending tracking events
152+
// before the Worker completes.
153+
//
154+
// waitUntil() ensures the tracking POST completes even after the
155+
// response is already sent to the visitor — no added latency.
156+
ctx.waitUntil(context.releaseQueues('edge-request-complete'));
155157

156158
return new Response(modifiedResponse.body, {
157159
status: modifiedResponse.status,
@@ -301,3 +303,23 @@ function applyVariation(
301303
// ctx.waitUntil(cache.put(cacheKey, cloned.clone()));
302304
// return cloned;
303305
// }
306+
307+
// ---------------------------------------------------------------------------
308+
// Optional: KV-Backed Visitor Persistence
309+
// ---------------------------------------------------------------------------
310+
311+
// If you need to persist bucketing decisions across experience config changes
312+
// (e.g. ensuring a visitor stays in the same variation even when rules change),
313+
// add KV support:
314+
//
315+
// 1. Add to Env: CONVERT_KV: KVNamespace;
316+
// 2. Add to wrangler.toml: [[kv_namespaces]] binding/id
317+
// 3. Use in the handler:
318+
//
319+
// import { KVDataStore } from '@convertcom/js-sdk-cloudflare';
320+
//
321+
// const dataStore = new KVDataStore(env.CONVERT_KV);
322+
// await dataStore.load(visitorId);
323+
// const context = convert.createContext(visitorId, { dataStore });
324+
// // ... run experiments ...
325+
// ctx.waitUntil(dataStore.save(visitorId));

demo/cloudflare-workers/wrangler.toml

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@ name = "convert-edge-experiments"
22
main = "src/index.ts"
33
compatibility_date = "2024-12-01"
44

5-
# KV namespace for config cache and visitor bucketing data.
6-
# Create with: wrangler kv namespace create CONVERT_KV
7-
# Then replace the id below with your actual namespace ID.
8-
[[kv_namespaces]]
9-
binding = "CONVERT_KV"
10-
id = "YOUR_KV_NAMESPACE_ID"
11-
12-
# For local development:
13-
# wrangler kv namespace create CONVERT_KV --preview
14-
# Then add:
15-
# preview_id = "YOUR_PREVIEW_NAMESPACE_ID"
16-
175
[vars]
186
# Your Convert SDK key (account_id/project_id)
197
CONVERT_SDK_KEY = "YOUR_ACCOUNT_ID/YOUR_PROJECT_ID"
8+
9+
# ------------------------------------------------------------------
10+
# Optional: KV namespace for persisting visitor bucketing data.
11+
# Only needed if you use KVDataStore (see demo/index.ts comments).
12+
# Most setups do NOT need this — the SDK uses deterministic bucketing.
13+
#
14+
# Create with: wrangler kv namespace create CONVERT_KV
15+
# Then uncomment and replace the id below.
16+
# ------------------------------------------------------------------
17+
# [[kv_namespaces]]
18+
# binding = "CONVERT_KV"
19+
# id = "YOUR_KV_NAMESPACE_ID"
20+
# preview_id = "YOUR_PREVIEW_NAMESPACE_ID"

packages/cloudflare/README.md

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @convertcom/js-sdk-cloudflare
22

3-
Cloudflare Workers utilities for the [Convert JavaScript SDK](https://github.com/convertcom/javascript-sdk). Provides the glue code between the FullStack SDK and Cloudflare-specific APIs (KV, HTMLRewriter, Workers Request/Response).
3+
Cloudflare Workers utilities for the [Convert JavaScript SDK](https://github.com/convertcom/javascript-sdk). Provides the glue code between the FullStack SDK and Cloudflare-specific APIs (HTMLRewriter, Workers Request/Response, edge caching).
44

55
## Installation
66

@@ -14,8 +14,8 @@ yarn add @convertcom/js-sdk @convertcom/js-sdk-cloudflare
1414

1515
| Export | Purpose |
1616
|--------|---------|
17-
| `EdgeConfigCache` | Cache SDK config in KV (~1ms reads vs ~100ms CDN) |
18-
| `KVDataStore` | KV-backed DataStore adapter for persisting bucketing decisions |
17+
| `EdgeConfigCache` | Cache SDK config using Cloudflare's native `fetch` cache (no KV needed) |
18+
| `KVDataStore` | **Optional** KV-backed DataStore adapter for persisting bucketing decisions |
1919
| `getVisitorId` | Parse visitor ID from Workers Request cookies |
2020
| `setVisitorIdCookie` | Set visitor ID cookie on Workers Response |
2121
| `generateVisitorId` | Generate a new UUID visitor ID |
@@ -28,16 +28,15 @@ yarn add @convertcom/js-sdk @convertcom/js-sdk-cloudflare
2828
import ConvertSDK from '@convertcom/js-sdk';
2929
import {
3030
EdgeConfigCache,
31-
KVDataStore,
3231
getVisitorId,
3332
setVisitorIdCookie,
3433
generateVisitorId
3534
} from '@convertcom/js-sdk-cloudflare';
3635

3736
export default {
3837
async fetch(request, env, ctx) {
39-
// 1. Get config from KV cache
40-
const config = await new EdgeConfigCache(env.CONVERT_KV, env.SDK_KEY).getConfig();
38+
// 1. Get config (cached at edge via Cloudflare's fetch cache — no KV)
39+
const config = await new EdgeConfigCache(env.SDK_KEY).getConfig();
4140

4241
// 2. Init SDK
4342
const sdk = new ConvertSDK({ data: config, network: { tracking: true } });
@@ -46,15 +45,11 @@ export default {
4645
// 3. Identify visitor
4746
const visitorId = getVisitorId(request) || generateVisitorId();
4847

49-
// 4. Load persisted bucketing from KV
50-
const dataStore = new KVDataStore(env.CONVERT_KV);
51-
await dataStore.load(visitorId);
52-
53-
// 5. Run experiment
48+
// 4. Run experiment (deterministic bucketing — no persistence needed)
5449
const context = sdk.createContext(visitorId);
5550
const variation = context.runExperience('my-experiment');
5651

57-
// 6. Modify the page with HTMLRewriter (zero flicker)
52+
// 5. Modify the page with HTMLRewriter (zero flicker)
5853
const origin = await fetch(request);
5954
let response = origin;
6055
if (variation?.key === 'variation-1') {
@@ -63,15 +58,12 @@ export default {
6358
.transform(origin);
6459
}
6560

66-
// 7. Set cookie and respond
61+
// 6. Set cookie and respond
6762
const headers = new Headers(response.headers);
6863
setVisitorIdCookie(headers, visitorId);
6964

70-
// 8. Save KV + flush tracking in background
71-
ctx.waitUntil(Promise.all([
72-
dataStore.save(visitorId),
73-
context.releaseQueues()
74-
]));
65+
// 7. Flush tracking events before Worker finishes
66+
ctx.waitUntil(context.releaseQueues());
7567

7668
return new Response(response.body, { status: response.status, headers });
7769
}

packages/cloudflare/src/edge-config-cache.ts

Lines changed: 22 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,106 +5,73 @@
55
* License Apache-2.0
66
*/
77

8-
/**
9-
* Minimal interface compatible with Cloudflare Workers KVNamespace.
10-
*/
11-
interface KVNamespaceLike {
12-
get(key: string): Promise<string | null>;
13-
put(
14-
key: string,
15-
value: string,
16-
options?: {expirationTtl?: number}
17-
): Promise<void>;
18-
}
19-
208
const DEFAULT_CONFIG_ENDPOINT = 'https://cdn-4.convertexperiments.com/api/v1';
219

2210
/**
23-
* Caches the Convert SDK configuration in Cloudflare KV.
11+
* Caches the Convert SDK configuration using Cloudflare's built-in fetch cache.
12+
*
13+
* Instead of requiring a KV namespace, this leverages the `cf` option on
14+
* `fetch()` to cache the config response at the edge. This is simpler,
15+
* works on all Cloudflare plans, and avoids KV read/write costs.
2416
*
25-
* Instead of fetching config from the CDN on every Worker invocation,
26-
* this cache stores it in KV with a TTL. This reduces latency from
27-
* ~100ms (CDN round-trip) to ~1ms (edge KV read).
17+
* @see https://developers.cloudflare.com/workers/examples/cache-using-fetch/
2818
*
2919
* @example
3020
* ```typescript
31-
* const configCache = new EdgeConfigCache(env.CONVERT_KV, 'YOUR_SDK_KEY');
21+
* const configCache = new EdgeConfigCache('YOUR_SDK_KEY');
3222
* const configData = await configCache.getConfig();
3323
*
3424
* const sdk = new ConvertSDK({ data: configData });
3525
* ```
3626
*/
3727
export class EdgeConfigCache {
38-
private _kv: KVNamespaceLike;
3928
private _sdkKey: string;
4029
private _ttl: number;
4130
private _configEndpoint: string;
4231

4332
/**
44-
* @param kv - A Cloudflare KV namespace binding
4533
* @param sdkKey - Your Convert SDK key (e.g. 'ACCOUNT_ID/PROJECT_ID')
4634
* @param ttl - Cache TTL in seconds (default: 300 = 5 minutes)
4735
* @param configEndpoint - Override the config CDN endpoint
4836
*/
49-
constructor(
50-
kv: KVNamespaceLike,
51-
sdkKey: string,
52-
ttl = 300,
53-
configEndpoint?: string
54-
) {
55-
this._kv = kv;
37+
constructor(sdkKey: string, ttl = 300, configEndpoint?: string) {
5638
this._sdkKey = sdkKey;
5739
this._ttl = ttl;
5840
this._configEndpoint = configEndpoint || DEFAULT_CONFIG_ENDPOINT;
5941
}
6042

6143
/**
62-
* Get the SDK configuration, serving from KV cache when available.
63-
* Falls back to fetching from the Convert CDN if cache is empty or expired.
44+
* Get the SDK configuration, served from Cloudflare's edge cache when
45+
* available. Falls back to fetching from the Convert CDN if the cache
46+
* entry has expired.
6447
*/
6548
async getConfig(): Promise<any> {
66-
const cacheKey = `config:${this._sdkKey}`;
67-
68-
// Try KV cache first
69-
const cached = await this._kv.get(cacheKey);
70-
if (cached) {
71-
try {
72-
return JSON.parse(cached);
73-
} catch (e) {
74-
// Corrupted data in KV, fall through to re-fetch from CDN.
75-
}
76-
}
77-
78-
// Cache miss or corrupted: fetch from CDN and store in KV
79-
return this._fetchAndCache(cacheKey);
49+
return this._fetch(this._ttl);
8050
}
8151

8252
/**
83-
* Force-refresh the configuration from the Convert CDN.
84-
* Use this for manual cache invalidation.
53+
* Force-refresh the configuration by bypassing the edge cache.
54+
* Use this for manual cache invalidation (e.g. via a cron trigger or webhook).
8555
*/
8656
async refreshConfig(): Promise<any> {
87-
const cacheKey = `config:${this._sdkKey}`;
88-
return this._fetchAndCache(cacheKey);
57+
return this._fetch(0);
8958
}
9059

91-
private async _fetchAndCache(cacheKey: string): Promise<any> {
60+
private async _fetch(cacheTtl: number): Promise<any> {
9261
const url = `${this._configEndpoint}/config/${this._sdkKey}`;
62+
// The `cf` property is a Cloudflare Workers extension to the standard
63+
// fetch API that controls edge caching behaviour.
9364
const response = await fetch(url, {
94-
headers: {'Content-Type': 'application/json'}
95-
});
65+
headers: {'Content-Type': 'application/json'},
66+
cf: {cacheTtl, cacheEverything: true}
67+
} as any);
9668

9769
if (!response.ok) {
9870
throw new Error(
9971
`Convert config fetch failed: ${response.status} ${response.statusText}`
10072
);
10173
}
10274

103-
const data = await response.json();
104-
await this._kv.put(cacheKey, JSON.stringify(data), {
105-
expirationTtl: this._ttl
106-
});
107-
108-
return data;
75+
return response.json();
10976
}
11077
}

0 commit comments

Comments
 (0)