@growthbook/edge-utils 0.2.4 → 0.2.6

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/dist/types.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface Config {
11
11
  forwardProxyHeaders: boolean;
12
12
  useDefaultContentType: boolean;
13
13
  processTextHtmlOnly: boolean;
14
+ autoInflate: boolean;
15
+ nocacheOrigin: boolean;
14
16
  environment: string;
15
17
  maxPayloadSize?: string;
16
18
  routes?: Route[];
@@ -21,6 +23,7 @@ export interface Config {
21
23
  runCrossOriginUrlRedirectExperiments: ExperimentRunEnvironment;
22
24
  injectRedirectUrlScript: boolean;
23
25
  maxRedirects: number;
26
+ experimentUrlTargeting?: ExperimentUrlTargeting;
24
27
  scriptInjectionPattern: string;
25
28
  disableInjections: boolean;
26
29
  enableStreaming: boolean;
@@ -36,6 +39,7 @@ export interface Config {
36
39
  clientKey: string;
37
40
  headers?: Record<string, string>;
38
41
  }) => Promise<any>;
42
+ emitTraceHeaders?: boolean;
39
43
  apiHost: string;
40
44
  clientKey: string;
41
45
  decryptionKey?: string;
@@ -51,12 +55,16 @@ export interface Config {
51
55
  skipAutoAttributes: boolean;
52
56
  }
53
57
  export type ExperimentRunEnvironment = "everywhere" | "edge" | "browser" | "skip";
58
+ export type ExperimentUrlTargeting = "request" | "origin";
59
+ export type FetchOptions = {
60
+ additionalHeaders?: Record<string, any>;
61
+ };
54
62
  export interface Helpers<Req, Res> {
55
63
  getRequestURL: (req: Req) => string;
56
64
  getRequestMethod: (req: Req) => string;
57
65
  getRequestHeader?: (req: Req, key: string) => string | undefined;
58
66
  sendResponse: (ctx: Context<Req, Res>, res?: Res, headers?: Record<string, any>, body?: string, cookies?: Record<string, string>, status?: number) => unknown;
59
- fetch: (ctx: Context<Req, Res>, url: string, req: Req) => Promise<Res>;
67
+ fetch: (ctx: Context<Req, Res>, url: string, req: Req, options?: FetchOptions) => Promise<Res>;
60
68
  proxyRequest: (ctx: Context<Req, Res>, req: Req, res?: Res, next?: any) => Promise<unknown>;
61
69
  getCookie?: (req: Req, key: string) => string;
62
70
  setCookie?: (res: Res, key: string, value: string) => void;
@@ -68,6 +76,7 @@ export type BaseHookParams<Req, Res> = {
68
76
  next?: any;
69
77
  requestUrl: string;
70
78
  originUrl: string;
79
+ requestCount: number;
71
80
  };
72
81
  export type OnRouteParams<Req, Res> = BaseHookParams<Req, Res> & {
73
82
  route: Route;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@growthbook/edge-utils",
3
3
  "description": "Edge worker base app",
4
- "version": "0.2.4",
4
+ "version": "0.2.6",
5
5
  "main": "dist/index.js",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -19,7 +19,7 @@
19
19
  "dev": "node scripts/generate-sdk-wrapper.js && tsc --watch"
20
20
  },
21
21
  "dependencies": {
22
- "@growthbook/growthbook": "^1.4.1",
22
+ "@growthbook/growthbook": "^1.6.1",
23
23
  "node-html-parser": "^7.0.1",
24
24
  "pako": "^2.1.0"
25
25
  },
package/src/app.ts CHANGED
@@ -5,7 +5,17 @@ import {
5
5
  setPolyfills,
6
6
  StickyBucketService
7
7
  } from "@growthbook/growthbook";
8
- import { Context } from "./types";
8
+ import {
9
+ BaseHookParams,
10
+ Context,
11
+ OnRouteParams,
12
+ OnUserAttributesParams,
13
+ OnGrowthBookInitParams,
14
+ OnBeforeOriginFetchParams,
15
+ OnOriginFetchParams,
16
+ OnBodyReadyParams,
17
+ OnBeforeResponseParams
18
+ } from "./types";
9
19
  import { getUserAttributes } from "./attributes";
10
20
  import { getCspInfo, injectScript } from "./inject";
11
21
  import { applyDomMutations } from "./domMutations";
@@ -42,9 +52,20 @@ export async function edgeApp<Req, Res>(
42
52
  const setRespCookie = (key: string, value: string) => {
43
53
  respCookies[key] = value;
44
54
  };
55
+
56
+ // Loop check
57
+ const requestCount = parseInt(context.helpers?.getRequestHeader?.(req, "x-gb-request-count") || "0") + 1;
58
+ if (requestCount > 1) {
59
+ console.error("Edge request loop detected. Count: " + requestCount, requestUrl);
60
+ }
61
+ if (requestCount > context.config.maxRedirects) {
62
+ throw new Error("Edge request loop: max requests reached: " + requestCount);
63
+ }
64
+
45
65
  // Initial hook:
46
66
  let hookResp: Res | undefined | void;
47
- hookResp = await context?.hooks?.onRequest?.({ context, req, res, next, requestUrl, originUrl });
67
+ let onRequestParams: BaseHookParams<Req, Res> = { context, req, res, next, requestUrl, originUrl, requestCount };
68
+ hookResp = await context?.hooks?.onRequest?.(onRequestParams);
48
69
  if (hookResp) return hookResp;
49
70
 
50
71
  // DOM mutations
@@ -72,7 +93,8 @@ export async function edgeApp<Req, Res>(
72
93
  return context.helpers.proxyRequest(context, req, res, next);
73
94
  }
74
95
  // Custom route behavior via hook:
75
- hookResp = await context?.hooks?.onRoute?.({ context, req, res, next, requestUrl, originUrl, route });
96
+ const onRouteParams: OnRouteParams<Req, Res> = { ...onRequestParams, route };
97
+ hookResp = await context?.hooks?.onRoute?.(onRouteParams);
76
98
  if (hookResp) return hookResp;
77
99
 
78
100
  /**
@@ -81,7 +103,8 @@ export async function edgeApp<Req, Res>(
81
103
  const attributes = getUserAttributes(context, req, requestUrl, setRespCookie);
82
104
 
83
105
  // Hook to allow enriching user attributes, etc
84
- hookResp = await context?.hooks?.onUserAttributes?.({ context, req, res, next, requestUrl, originUrl, route, attributes });
106
+ const onUserAttributesParams: OnUserAttributesParams<Req, Res> = { ...onRouteParams, attributes };
107
+ hookResp = await context?.hooks?.onUserAttributes?.(onUserAttributesParams);
85
108
  if (hookResp) return hookResp;
86
109
 
87
110
  /**
@@ -116,7 +139,7 @@ export async function edgeApp<Req, Res>(
116
139
  return () => {
117
140
  };
118
141
  },
119
- url: requestUrl,
142
+ url: context.config.experimentUrlTargeting === "origin" ? originUrl: requestUrl,
120
143
  disableVisualExperiments: ["skip", "browser"].includes(
121
144
  context.config.runVisualEditorExperiments,
122
145
  ),
@@ -136,7 +159,8 @@ export async function edgeApp<Req, Res>(
136
159
  });
137
160
 
138
161
  // Hook to perform any custom logic given the initialized SDK
139
- hookResp = await context?.hooks?.onGrowthbookInit?.({ context, req, res, next, requestUrl, originUrl, route, attributes, growthbook });
162
+ const onGrowthbookInitParams: OnGrowthBookInitParams<Req, Res> = { ...onUserAttributesParams, growthbook };
163
+ hookResp = await context?.hooks?.onGrowthbookInit?.(onGrowthbookInitParams);
140
164
  if (hookResp) return hookResp;
141
165
 
142
166
  /**
@@ -147,14 +171,15 @@ export async function edgeApp<Req, Res>(
147
171
  req,
148
172
  setRespCookie,
149
173
  growthbook,
150
- previousUrl: requestUrl,
174
+ previousUrl: context.config.experimentUrlTargeting === "origin" ? originUrl : requestUrl,
151
175
  resetDomChanges,
152
176
  setPreRedirectChangeIds: setPreRedirectChangeIds,
153
177
  });
154
178
  originUrl = getOriginUrl(context, redirectRequestUrl, redirectRequestUrl !== requestUrl);
155
179
 
156
180
  // Pre-origin-fetch hook (after redirect logic):
157
- hookResp = await context?.hooks?.onBeforeOriginFetch?.({ context, req, res, next, requestUrl, redirectRequestUrl, originUrl, route, attributes, growthbook });
181
+ const onBeforeOriginFetchParams: OnBeforeOriginFetchParams<Req, Res> = { ...onGrowthbookInitParams, redirectRequestUrl, originUrl };
182
+ hookResp = await context?.hooks?.onBeforeOriginFetch?.(onBeforeOriginFetchParams);
158
183
  if (hookResp) return hookResp;
159
184
 
160
185
  /**
@@ -165,6 +190,12 @@ export async function edgeApp<Req, Res>(
165
190
  context,
166
191
  originUrl,
167
192
  req,
193
+ context.config.emitTraceHeaders ? {
194
+ additionalHeaders: {
195
+ "x-gb-request-count": ("" + requestCount),
196
+ "x-gbuuid": growthbook.getAttributes()?.[context.config.uuidKey],
197
+ }
198
+ } : undefined,
168
199
  ) as OriginResponse & Res;
169
200
  } catch (e) {
170
201
  console.error(e);
@@ -172,12 +203,12 @@ export async function edgeApp<Req, Res>(
172
203
  const originStatus = originResponse ? parseInt(originResponse.status ? originResponse.status + "" : "400") : 500;
173
204
 
174
205
  // On fetch hook (for custom response processing, etc)
175
- hookResp = await context?.hooks?.onOriginFetch?.({ context, req, res, next, requestUrl, redirectRequestUrl, originUrl, route, attributes, growthbook, originResponse, originStatus });
206
+ const onOriginFetchParams: OnOriginFetchParams<Req, Res> = { ...onBeforeOriginFetchParams, originResponse, originStatus };
207
+ hookResp = await context?.hooks?.onOriginFetch?.(onOriginFetchParams);
176
208
  if (hookResp) return hookResp;
177
209
 
178
210
  // Standard error response handling
179
211
  if (originStatus >= 500 || !originResponse) {
180
- console.error("Fetch: 5xx status returned");
181
212
  return context.helpers.sendResponse(context, res, {}, "Error fetching page", {}, 500);
182
213
  }
183
214
  if (originStatus >= 400) {
@@ -204,18 +235,29 @@ export async function edgeApp<Req, Res>(
204
235
  resHeaders["content-security-policy"] = csp;
205
236
  }
206
237
 
207
- let body = await originResponse.text() ?? "";
208
- // Check if content-encoding is gzip
209
- if (originHeaders["content-encoding"] === "gzip") {
210
- delete resHeaders["content-encoding"]; // do not forward this header since it's now unzipped
211
- if (!body) {
212
- try {
213
- const buffer = await originResponse.arrayBuffer();
238
+ const autoInflate = context.config.autoInflate; // fastly only!!!
239
+ let body = "";
240
+ const isGzipped = originHeaders["content-encoding"] === "gzip";
241
+ try {
242
+ const buffer = await originResponse.arrayBuffer();
243
+ try {
244
+ if (isGzipped && autoInflate) {
245
+ body = pako.inflate(new Uint8Array(buffer), { to: "string" });
246
+ } else {
247
+ body = new TextDecoder().decode(buffer);
248
+ }
249
+ } catch {
250
+ if (isGzipped) {
214
251
  body = pako.inflate(new Uint8Array(buffer), { to: "string" });
215
- } catch (e) {
216
- console.error(e);
252
+ } else {
253
+ throw new Error("Failed to decode response as text.");
217
254
  }
218
255
  }
256
+ if (isGzipped) {
257
+ delete resHeaders["content-encoding"]; // Remove to prevent double decompression
258
+ }
259
+ } catch (e) {
260
+ console.error("Response decoding error", e);
219
261
  }
220
262
  let setBody = (s: string) => {
221
263
  body = s;
@@ -227,7 +269,8 @@ export async function edgeApp<Req, Res>(
227
269
  }
228
270
 
229
271
  // Body ready hook (pre-DOM-mutations):
230
- hookResp = await context?.hooks?.onBodyReady?.({ context, req, res, next, requestUrl, redirectRequestUrl, originUrl, route, attributes, growthbook, originResponse, originStatus, originHeaders, resHeaders, body, setBody, root });
272
+ const onBodyReadyParams: OnBodyReadyParams<Req, Res> = { ...onOriginFetchParams, originHeaders, resHeaders, body, setBody, root };
273
+ hookResp = await context?.hooks?.onBodyReady?.(onBodyReadyParams);
231
274
  if (hookResp) return hookResp;
232
275
 
233
276
  /**
@@ -257,7 +300,8 @@ export async function edgeApp<Req, Res>(
257
300
  });
258
301
 
259
302
  // Final hook (post-mutations) before sending back
260
- hookResp = await context?.hooks?.onBeforeResponse?.({ context, req, res, next, requestUrl, redirectRequestUrl, originUrl, route, attributes, growthbook, originResponse, originStatus, originHeaders, resHeaders, body, setBody });
303
+ const onBeforeResponseParams: OnBeforeResponseParams<Req, Res> = { ...onOriginFetchParams, originHeaders, resHeaders, body, setBody };
304
+ hookResp = await context?.hooks?.onBeforeResponse?.(onBeforeResponseParams);
261
305
  if (hookResp) return hookResp;
262
306
 
263
307
  /**
package/src/attributes.ts CHANGED
@@ -7,6 +7,7 @@ export function getUserAttributes<Req, Res>(
7
7
  req: Req,
8
8
  url: string,
9
9
  setRespCookie: (key: string, value: string) => void,
10
+ skipUuid: boolean = false,
10
11
  ): Attributes {
11
12
  const { config, helpers } = ctx;
12
13
 
@@ -14,16 +15,18 @@ export function getUserAttributes<Req, Res>(
14
15
  if (config.skipAutoAttributes) {
15
16
  return providedAttributes;
16
17
  }
17
- // get any saved attributes from the cookie
18
- const uuid = getUUID(ctx, req);
19
- if (config.persistUuid && !config.noAutoCookies) {
20
- if (!helpers?.setCookie) {
21
- throw new Error("Missing required dependencies");
18
+ if (!skipUuid) {
19
+ // get any saved attributes from the cookie
20
+ const uuid = getUUID(ctx, req);
21
+ if (config.persistUuid && !config.noAutoCookies) {
22
+ if (!helpers?.setCookie) {
23
+ throw new Error("Missing required dependencies");
24
+ }
25
+ setRespCookie(config.uuidCookieName, uuid);
22
26
  }
23
- setRespCookie(config.uuidCookieName, uuid);
24
27
  }
25
28
 
26
- const autoAttributes = getAutoAttributes(ctx, req, url);
29
+ const autoAttributes = getAutoAttributes(ctx, req, url, skipUuid);
27
30
  return { ...autoAttributes, ...providedAttributes };
28
31
  }
29
32
 
@@ -50,7 +53,9 @@ export function getUUID<Req, Res>(ctx: Context<Req, Res>, req: Req) {
50
53
  };
51
54
 
52
55
  // get the existing UUID from cookie if set, otherwise create one
53
- return helpers.getCookie(req, config.uuidCookieName) || genUUID();
56
+ return helpers.getCookie(req, config.uuidCookieName)
57
+ || helpers?.getRequestHeader?.(req, "x-gbuuid")
58
+ || genUUID();
54
59
  }
55
60
 
56
61
  // Infer attributes from the request
@@ -60,14 +65,17 @@ export function getAutoAttributes<Req, Res>(
60
65
  ctx: Context<Req, Res>,
61
66
  req: Req,
62
67
  url: string,
68
+ skipUuid: boolean = false,
63
69
  ): Attributes {
64
70
  const { config, helpers } = ctx;
65
71
 
66
72
  const getHeader = helpers?.getRequestHeader;
67
73
 
68
- let autoAttributes: Attributes = {
69
- [config.uuidKey]: getUUID(ctx, req),
70
- };
74
+ let autoAttributes: Attributes = skipUuid
75
+ ? {}
76
+ : {
77
+ [config.uuidKey]: getUUID(ctx, req),
78
+ };
71
79
 
72
80
  const ua = getHeader?.(req, "user-agent") || "";
73
81
  autoAttributes.browser = ua.match(/Edg/)
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Config, Context, ExperimentRunEnvironment } from "./types";
1
+ import { Config, Context, ExperimentRunEnvironment, ExperimentUrlTargeting } from "./types";
2
2
 
3
3
  type Req = any; // placeholder
4
4
  type Res = any; // placeholder
@@ -10,6 +10,8 @@ export const defaultContext: Context<Req, Res> = {
10
10
  followRedirects: true,
11
11
  useDefaultContentType: false,
12
12
  processTextHtmlOnly: true,
13
+ autoInflate: false,
14
+ nocacheOrigin: false,
13
15
  environment: "production",
14
16
  maxPayloadSize: "2mb",
15
17
  runVisualEditorExperiments: "everywhere",
@@ -19,10 +21,12 @@ export const defaultContext: Context<Req, Res> = {
19
21
  runCrossOriginUrlRedirectExperiments: "browser",
20
22
  injectRedirectUrlScript: true,
21
23
  maxRedirects: 5,
24
+ experimentUrlTargeting: "request",
22
25
  scriptInjectionPattern: "</head>",
23
26
  disableInjections: false,
24
27
  enableStreaming: false,
25
28
  enableStickyBucketing: false,
29
+ emitTraceHeaders: true,
26
30
  apiHost: "",
27
31
  clientKey: "",
28
32
  persistUuid: false,
@@ -57,6 +61,8 @@ export interface ConfigEnv {
57
61
  FOLLOW_REDIRECTS?: string;
58
62
  USE_DEFAULT_CONTENT_TYPE?: string;
59
63
  PROCESS_TEXT_HTML_ONLY?: string;
64
+ AUTO_INFLATE?: string;
65
+ NOCACHE_ORIGIN?: string;
60
66
  NODE_ENV?: string;
61
67
  MAX_PAYLOAD_SIZE?: string;
62
68
 
@@ -71,6 +77,8 @@ export interface ConfigEnv {
71
77
  INJECT_REDIRECT_URL_SCRIPT?: string;
72
78
  MAX_REDIRECTS?: string;
73
79
 
80
+ EXPERIMENT_URL_TARGETING?: ExperimentUrlTargeting;
81
+
74
82
  SCRIPT_INJECTION_PATTERN?: string;
75
83
  DISABLE_INJECTIONS?: string;
76
84
 
@@ -80,6 +88,7 @@ export interface ConfigEnv {
80
88
 
81
89
  CONTENT_SECURITY_POLICY?: string;
82
90
  NONCE?: string;
91
+ EMIT_TRACE_HEADERS?: string;
83
92
 
84
93
  GROWTHBOOK_API_HOST?: string;
85
94
  GROWTHBOOK_CLIENT_KEY?: string;
@@ -116,6 +125,12 @@ export function getConfig(env: ConfigEnv): Config {
116
125
  config.processTextHtmlOnly = ["true", "1"].includes(
117
126
  env.PROCESS_TEXT_HTML_ONLY ?? "" + defaultContext.config.processTextHtmlOnly,
118
127
  );
128
+ config.autoInflate = ["true", "1"].includes(
129
+ env.AUTO_INFLATE ?? "" + defaultContext.config.autoInflate,
130
+ );
131
+ config.nocacheOrigin = ["true", "1"].includes(
132
+ env.NOCACHE_ORIGIN ?? "" + defaultContext.config.nocacheOrigin,
133
+ );
119
134
  config.environment = env.NODE_ENV ?? defaultContext.config.environment;
120
135
  config.maxPayloadSize =
121
136
  env.MAX_PAYLOAD_SIZE ?? defaultContext.config.maxPayloadSize;
@@ -152,6 +167,10 @@ export function getConfig(env: ConfigEnv): Config {
152
167
  env.MAX_REDIRECTS || "" + defaultContext.config.maxRedirects,
153
168
  );
154
169
 
170
+ config.experimentUrlTargeting = (env.EXPERIMENT_URL_TARGETING ??
171
+ defaultContext.config
172
+ .experimentUrlTargeting) as ExperimentUrlTargeting;
173
+
155
174
  config.scriptInjectionPattern =
156
175
  env.SCRIPT_INJECTION_PATTERN ||
157
176
  defaultContext.config.scriptInjectionPattern;
@@ -175,6 +194,10 @@ export function getConfig(env: ConfigEnv): Config {
175
194
 
176
195
  config.crypto = crypto;
177
196
 
197
+ config.emitTraceHeaders = ["true", "1"].includes(
198
+ env.EMIT_TRACE_HEADERS ?? "" + defaultContext.config.emitTraceHeaders,
199
+ );
200
+
178
201
  // growthbook
179
202
  config.apiHost = (env.GROWTHBOOK_API_HOST ?? "").replace(/\/*$/, "");
180
203
  config.clientKey = env.GROWTHBOOK_CLIENT_KEY ?? "";