Skip to content
This repository was archived by the owner on Dec 12, 2025. It is now read-only.

Commit 79d13e9

Browse files
authored
allow configuring or disabling visitorKey cookie (#55)
* allow configuring or disabling visitorKey cookie * use default serializeVisitorKeyCookie for example
1 parent d50676d commit 79d13e9

File tree

7 files changed

+165
-133
lines changed

7 files changed

+165
-133
lines changed

.changeset/small-tables-know.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@happykit/flags": minor
3+
---
4+
5+
allow disabling visitor key cookie
6+
7+
You can now configure or disable the visitor key cookie `@happykit/flags` sets by default. You can pass a `serializeVisitorKeyCookie` function to the options when calling `createUseFlags` and `createGetFlags`.

example/flags/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,7 @@ export const config: Configuration<AppFlags> = {
1313
// You can just delete this line in your own application.
1414
// It's only here because we use it while working on @happykit/flags itself.
1515
endpoint: process.env.NEXT_PUBLIC_FLAGS_ENDPOINT,
16+
17+
// You can uncomment this if you do not want to set the visitorKey cookie
18+
// serializeVisitorKeyCookie: () => null,
1619
};

package/src/client.ts

Lines changed: 115 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
import {
2424
deepEqual,
2525
getCookie,
26-
serializeVisitorKeyCookie,
2726
combineRawFlagsWithDefaultFlags,
2827
ObjectMap,
2928
has,
@@ -165,134 +164,139 @@ function canSettle<F extends Flags>(state: State<F>) {
165164
);
166165
}
167166

168-
/**
169-
* The reducer returns a tuple of [state, effects].
170-
*
171-
* effects is an array of effects to execute. The emitted effects are then later
172-
* executed in another hook.
173-
*
174-
* This pattern is basically a hand-rolled version of
175-
* https://github.com/davidkpiano/useEffectReducer
176-
*
177-
* We use a hand-rolled version to keep the size of this package minimal.
178-
*/
179-
function reducer<F extends Flags>(
180-
tuple: readonly [State<F>, Effect[]],
181-
action: Action<F>
182-
): readonly [State<F>, Effect[]] {
183-
const [state /* and effects */] = tuple;
184-
185-
switch (action.type) {
186-
case "evaluate": {
187-
const cachedOutcome = cache.get<SuccessOutcome<F>>(action.input);
188-
189-
const [effects, pending] = createFetchEffects<F>(
190-
action.input,
191-
state.pending
192-
);
193-
194-
// action.input will always differ from state.input, because we do not
195-
// dispatch "evaluate" otherwise
196-
return [
197-
{
198-
name: "evaluating",
199-
input: action.input,
200-
cachedOutcome,
201-
pending,
202-
},
203-
effects,
204-
];
205-
}
206-
case "revalidate": {
207-
if (state.name === "empty") return tuple;
208-
209-
const input = action.input || state.input;
210-
const [effects, pending] = createFetchEffects<F>(input, state.pending);
211-
212-
if (state.name === "succeeded")
167+
function createReducer<F extends Flags>(config: FullConfiguration<F>) {
168+
/**
169+
* The reducer returns a tuple of [state, effects].
170+
*
171+
* effects is an array of effects to execute. The emitted effects are then later
172+
* executed in another hook.
173+
*
174+
* This pattern is basically a hand-rolled version of
175+
* https://github.com/davidkpiano/useEffectReducer
176+
*
177+
* We use a hand-rolled version to keep the size of this package minimal.
178+
*/
179+
return function reducer<F extends Flags>(
180+
tuple: readonly [State<F>, Effect[]],
181+
action: Action<F>
182+
): readonly [State<F>, Effect[]] {
183+
const [state /* and effects */] = tuple;
184+
185+
switch (action.type) {
186+
case "evaluate": {
187+
const cachedOutcome = cache.get<SuccessOutcome<F>>(action.input);
188+
189+
const [effects, pending] = createFetchEffects<F>(
190+
action.input,
191+
state.pending
192+
);
193+
194+
// action.input will always differ from state.input, because we do not
195+
// dispatch "evaluate" otherwise
213196
return [
214197
{
215-
name: "revalidating-after-success",
216-
input: state.input,
217-
outcome: state.outcome,
218-
cachedOutcome: state.cachedOutcome,
198+
name: "evaluating",
199+
input: action.input,
200+
cachedOutcome,
219201
pending,
220202
},
221203
effects,
222204
];
205+
}
206+
case "revalidate": {
207+
if (state.name === "empty") return tuple;
223208

224-
if (state.name === "failed")
225-
return [
226-
{
227-
name: "revalidating-after-failure",
228-
input: state.input,
229-
outcome: state.outcome,
230-
cachedOutcome: state.cachedOutcome,
231-
pending,
232-
},
233-
effects,
234-
];
209+
const input = action.input || state.input;
210+
const [effects, pending] = createFetchEffects<F>(input, state.pending);
211+
212+
if (state.name === "succeeded")
213+
return [
214+
{
215+
name: "revalidating-after-success",
216+
input: state.input,
217+
outcome: state.outcome,
218+
cachedOutcome: state.cachedOutcome,
219+
pending,
220+
},
221+
effects,
222+
];
223+
224+
if (state.name === "failed")
225+
return [
226+
{
227+
name: "revalidating-after-failure",
228+
input: state.input,
229+
outcome: state.outcome,
230+
cachedOutcome: state.cachedOutcome,
231+
pending,
232+
},
233+
effects,
234+
];
235+
236+
if (state.name === "evaluating")
237+
return [
238+
{
239+
name: "evaluating",
240+
input: state.input,
241+
outcome: state.outcome,
242+
cachedOutcome: state.cachedOutcome,
243+
pending,
244+
},
245+
effects,
246+
];
235247

236-
if (state.name === "evaluating")
248+
return tuple;
249+
}
250+
case "settle/failure": {
251+
if (!canSettle(state)) return tuple;
252+
253+
// ignore outdated responses
254+
if (state.pending?.id !== action.id) return tuple;
255+
256+
if (action.thrownError) {
257+
console.error("@happykit/flags: Failed to load flags");
258+
console.error(action.thrownError);
259+
}
260+
261+
const cachedOutcome = cache.get<SuccessOutcome<F>>(action.input);
237262
return [
238263
{
239-
name: "evaluating",
240-
input: state.input,
241-
outcome: state.outcome,
242-
cachedOutcome: state.cachedOutcome,
243-
pending,
264+
name: "failed",
265+
input: action.input,
266+
outcome: action.outcome,
267+
cachedOutcome,
244268
},
245-
effects,
269+
[],
246270
];
247-
248-
return tuple;
249-
}
250-
case "settle/failure": {
251-
if (!canSettle(state)) return tuple;
252-
253-
// ignore outdated responses
254-
if (state.pending?.id !== action.id) return tuple;
255-
256-
if (action.thrownError) {
257-
console.error("@happykit/flags: Failed to load flags");
258-
console.error(action.thrownError);
259271
}
272+
case "settle/success": {
273+
if (!canSettle(state)) return tuple;
260274

261-
const cachedOutcome = cache.get<SuccessOutcome<F>>(action.input);
262-
return [
263-
{
264-
name: "failed",
265-
input: action.input,
266-
outcome: action.outcome,
267-
cachedOutcome,
268-
},
269-
[],
270-
];
271-
}
272-
case "settle/success": {
273-
if (!canSettle(state)) return tuple;
275+
// ignore outdated responses
276+
if (state.pending?.id !== action.id) return tuple;
274277

275-
// ignore outdated responses
276-
if (state.pending?.id !== action.id) return tuple;
278+
const visitorKey = action.outcome.data.visitor?.key;
279+
if (visitorKey) {
280+
const visitorKeyCookie = config.serializeVisitorKeyCookie(visitorKey);
281+
if (visitorKeyCookie) document.cookie = visitorKeyCookie;
282+
}
277283

278-
const visitorKey = action.outcome.data.visitor?.key;
279-
if (visitorKey) document.cookie = serializeVisitorKeyCookie(visitorKey);
284+
cache.set(action.input, action.outcome);
280285

281-
cache.set(action.input, action.outcome);
286+
return [
287+
{
288+
name: "succeeded",
289+
input: action.input,
290+
outcome: action.outcome,
291+
},
292+
[],
293+
];
294+
}
282295

283-
return [
284-
{
285-
name: "succeeded",
286-
input: action.input,
287-
outcome: action.outcome,
288-
},
289-
[],
290-
];
296+
default:
297+
return tuple;
291298
}
292-
293-
default:
294-
return tuple;
295-
}
299+
};
296300
}
297301

298302
function getInput<F extends Flags>({
@@ -409,6 +413,7 @@ export function createUseFlags<F extends Flags>(
409413
}: FactoryUseFlagOptions = {}
410414
) {
411415
const config = applyConfigurationDefaults(configuration);
416+
const reducer = createReducer(config);
412417

413418
return function useFlags(options: UseFlagsOptions<F> = {}): FlagBag<F> {
414419
useOnce();

package/src/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,17 @@ export type Configuration<F extends Flags> = {
4949
* @default "https://happykit.dev/api/flags"
5050
*/
5151
endpoint?: string;
52+
53+
/**
54+
* Gets called with a visitorKey and must return a string which can be assigned to `document.cookie` or null.
55+
*
56+
* The name of the cookie you set within that string must be `hkvk` (short for "happykit visitor key").
57+
*
58+
* If the option is undefined, it will use the default implementation which sets the cookie for 180 days.
59+
* You can override this with your own behavior or write a function which returns null to set no cookie at all.
60+
*
61+
* @param visitorKey the randomly assigned key of the visitor
62+
* @returns null or cookie to set, for example `hkvk=${encodeURIComponent(visitorKey)}; Path=/; Max-Age=15552000; SameSite=Lax`
63+
*/
64+
serializeVisitorKeyCookie?: (visitorKey: string) => string | null;
5265
};

package/src/internal/apply-configuration-defaults.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,24 @@ import type { Flags, FullConfiguration } from "./types";
22
import type { Configuration } from "../config";
33
import type { Environment } from "../evaluation-types";
44

5+
function serializeVisitorKeyCookie(visitorKey: string) {
6+
// Max-Age 15552000 seconds equals 180 days
7+
return `hkvk=${encodeURIComponent(
8+
visitorKey
9+
)}; Path=/; Max-Age=15552000; SameSite=Lax`;
10+
}
11+
512
export function applyConfigurationDefaults<F extends Flags>(
613
incomingConfig: Configuration<F>
714
) {
815
if (!incomingConfig) throw new Error("@happykit/flags: config missing");
916
if (!incomingConfig.envKey || incomingConfig.envKey.length === 0)
1017
throw new Error("@happykit/flags: envKey missing");
1118

12-
const defaults = {
19+
const defaults: Partial<Configuration<F>> = {
1320
endpoint: "https://happykit.dev/api/flags",
1421
defaultFlags: {} as F,
22+
serializeVisitorKeyCookie,
1523
};
1624

1725
const match = incomingConfig.envKey.match(

package/src/internal/utils.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,6 @@ export function getCookie(
6868
return null;
6969
}
7070

71-
export function serializeVisitorKeyCookie(visitorKey: string) {
72-
// Max-Age 15552000 seconds equals 180 days
73-
return `hkvk=${encodeURIComponent(
74-
visitorKey
75-
)}; Path=/; Max-Age=15552000; SameSite=Lax`;
76-
}
77-
7871
// source: https://github.com/lukeed/dequal/blob/master/src/lite.js
7972
export function deepEqual(objA: any, objB: any) {
8073
var ctor, len;

package/src/server.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import type {
1818
} from "./internal/types";
1919
import {
2020
has,
21-
serializeVisitorKeyCookie,
2221
combineRawFlagsWithDefaultFlags,
2322
getCookie,
2423
} from "./internal/utils";
@@ -247,13 +246,16 @@ export function createGetFlags<F extends Flags>(
247246
}
248247

249248
if (options.context && has(options.context, "req") && visitorKey) {
250-
// always set the cookie so its max age refreshes
251-
(
252-
options.context as {
249+
const visitorKeyCookie = config.serializeVisitorKeyCookie(visitorKey);
250+
251+
if (visitorKeyCookie) {
252+
// always set the cookie so its max age refreshes
253+
const context = options.context as {
253254
req: IncomingMessage;
254255
res: ServerResponse;
255-
}
256-
).res.setHeader("Set-Cookie", serializeVisitorKeyCookie(visitorKey));
256+
};
257+
context.res.setHeader("Set-Cookie", visitorKeyCookie);
258+
}
257259
}
258260

259261
const evaluated = evaluate({
@@ -321,16 +323,17 @@ export function createGetFlags<F extends Flags>(
321323
has(options.context, "req") &&
322324
outcomeData.visitor?.key
323325
) {
324-
// always set the cookie so its max age refreshes
325-
(
326-
options.context as {
327-
req: IncomingMessage;
328-
res: ServerResponse;
329-
}
330-
).res.setHeader(
331-
"Set-Cookie",
332-
serializeVisitorKeyCookie(outcomeData.visitor.key)
326+
const visitorKeyCookie = config.serializeVisitorKeyCookie(
327+
outcomeData.visitor.key
333328
);
329+
// always set the cookie so its max age refreshes
330+
const context = options.context as {
331+
req: IncomingMessage;
332+
res: ServerResponse;
333+
};
334+
if (visitorKeyCookie) {
335+
context.res.setHeader("Set-Cookie", visitorKeyCookie);
336+
}
334337
}
335338

336339
// add defaults to flags here, but not in initialFlagState

0 commit comments

Comments
 (0)