@growthbook/edge-utils 0.1.7 → 0.2.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/src/app.ts CHANGED
@@ -12,6 +12,15 @@ import { applyDomMutations } from "./domMutations";
12
12
  import redirect from "./redirect";
13
13
  import { getRoute } from "./routing";
14
14
  import { EdgeStickyBucketService } from "./stickyBucketService";
15
+ import { HTMLElement, parse } from "node-html-parser";
16
+ import pako from "pako";
17
+
18
+ interface OriginResponse {
19
+ status: number;
20
+ headers: Record<string, string | undefined>;
21
+ text: () => Promise<string>;
22
+ arrayBuffer: () => Promise<ArrayBuffer>;
23
+ }
15
24
 
16
25
  export async function edgeApp<Req, Res>(
17
26
  context: Context<Req, Res>,
@@ -20,68 +29,78 @@ export async function edgeApp<Req, Res>(
20
29
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
30
  next?: any,
22
31
  ) {
23
- let url = context.helpers.getRequestURL?.(req) || "";
24
-
25
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
- let headers: Record<string, any> = {
27
- "Content-Type": "text/html",
32
+ /**
33
+ * 1. Init app variables
34
+ */
35
+ // Request vars:
36
+ let requestUrl = context.helpers.getRequestURL(req);
37
+ let originUrl = getOriginUrl(context, requestUrl);
38
+ // Response vars:
39
+ let originResponse: (OriginResponse & Res) | undefined = undefined;
40
+ let resHeaders: Record<string, string | undefined> = {};
41
+ const respCookies: Record<string, string> = {};
42
+ const setRespCookie = (key: string, value: string) => {
43
+ respCookies[key] = value;
28
44
  };
29
- const cookies: Record<string, string> = {};
30
- const setCookie = (key: string, value: string) => {
31
- cookies[key] = value;
32
- };
33
- const { csp, nonce } = getCspInfo(context as Context<unknown, unknown>);
34
- if (csp) {
35
- headers["Content-Security-Policy"] = csp;
36
- }
37
- let body = "";
45
+ // Initial hook:
46
+ let hookResp: Res | undefined | void;
47
+ hookResp = await context?.hooks?.onRequest?.({ context, req, res, next, requestUrl, originUrl });
48
+ if (hookResp) return hookResp;
49
+
50
+ // DOM mutations
51
+ let domChanges: AutoExperimentVariation[] = [];
52
+ const resetDomChanges = () => (domChanges = []);
53
+
54
+ // Experiments that triggered prior to final redirect
55
+ let preRedirectChangeIds: string[] = [];
56
+ const setPreRedirectChangeIds = (changeIds: string[]) =>
57
+ (preRedirectChangeIds = changeIds);
38
58
 
59
+ /**
60
+ * 2. Early exits based on method, routes, etc
61
+ */
39
62
  // Non GET requests are proxied
40
- if (context.helpers.getRequestMethod?.(req) !== "GET") {
41
- return context.helpers.proxyRequest?.(context, req, res, next);
63
+ if (context.helpers.getRequestMethod(req) !== "GET") {
64
+ return context.helpers.proxyRequest(context, req, res, next);
42
65
  }
43
66
  // Check the url for routing rules (default behavior is intercept)
44
- const route = getRoute(context as Context<unknown, unknown>, url);
67
+ const route = getRoute(context, requestUrl);
45
68
  if (route.behavior === "error") {
46
- return context.helpers.sendResponse?.(
47
- context,
48
- res,
49
- headers,
50
- route.body || "",
51
- cookies,
52
- route.statusCode,
53
- );
69
+ return context.helpers.sendResponse(context, res, {}, route.body || "", {}, route.statusCode);
54
70
  }
55
71
  if (route.behavior === "proxy") {
56
- return context.helpers.proxyRequest?.(context, req, res, next);
72
+ return context.helpers.proxyRequest(context, req, res, next);
57
73
  }
74
+ // Custom route behavior via hook:
75
+ hookResp = await context?.hooks?.onRoute?.({ context, req, res, next, requestUrl, originUrl, route });
76
+ if (hookResp) return hookResp;
58
77
 
59
- const attributes = getUserAttributes(context, req, url, setCookie);
60
-
61
- let domChanges: AutoExperimentVariation[] = [];
62
- const resetDomChanges = () => (domChanges = []);
78
+ /**
79
+ * 3. User attributes & uuid
80
+ */
81
+ const attributes = getUserAttributes(context, req, requestUrl, setRespCookie);
63
82
 
64
- let preRedirectChangeIds: string[] = [];
65
- const setPreRedirectChangeIds = (changeIds: string[]) =>
66
- (preRedirectChangeIds = changeIds);
83
+ // Hook to allow enriching user attributes, etc
84
+ hookResp = await context?.hooks?.onUserAttributes?.({ context, req, res, next, requestUrl, originUrl, route, attributes });
85
+ if (hookResp) return hookResp;
67
86
 
68
- if (context.config.localStorage)
69
- setPolyfills({ localStorage: context.config.localStorage });
70
- if (context.config.crypto)
71
- setPolyfills({ SubtleCrypto: context.config.crypto });
87
+ /**
88
+ * 4. Init GrowthBook SDK
89
+ */
90
+ setPolyfills({
91
+ localStorage: context.config?.localStorage,
92
+ SubtleCrypto: context.config?.crypto,
93
+ });
72
94
  if (context.config.staleTTL !== undefined)
73
95
  configureCache({ staleTTL: context.config.staleTTL });
74
96
  if (context.config.fetchFeaturesCall)
75
97
  helpers.fetchFeaturesCall = context.config.fetchFeaturesCall;
76
98
 
77
- let stickyBucketService:
78
- | EdgeStickyBucketService<Req, Res>
79
- | StickyBucketService
80
- | undefined = undefined;
99
+ let stickyBucketService: EdgeStickyBucketService<Req, Res> | StickyBucketService | undefined;
81
100
  if (context.config.enableStickyBucketing) {
82
101
  stickyBucketService =
83
102
  context.config.edgeStickyBucketService ??
84
- new EdgeStickyBucketService<Req, Res>({
103
+ new EdgeStickyBucketService({
85
104
  context,
86
105
  prefix: context.config.stickyBucketPrefix,
87
106
  req,
@@ -94,9 +113,10 @@ export async function edgeApp<Req, Res>(
94
113
  attributes,
95
114
  applyDomChangesCallback: (changes: AutoExperimentVariation) => {
96
115
  domChanges.push(changes);
97
- return () => {};
116
+ return () => {
117
+ };
98
118
  },
99
- url,
119
+ url: requestUrl,
100
120
  disableVisualExperiments: ["skip", "browser"].includes(
101
121
  context.config.runVisualEditorExperiments,
102
122
  ),
@@ -115,81 +135,140 @@ export async function edgeApp<Req, Res>(
115
135
  payload: context.config.payload,
116
136
  });
117
137
 
118
- const oldUrl = url;
119
- url = await redirect({
120
- context: context as Context<unknown, unknown>,
138
+ // 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 });
140
+ if (hookResp) return hookResp;
141
+
142
+
143
+ /**
144
+ * 5. Run URL redirect tests before fetching from origin
145
+ */
146
+ const redirectRequestUrl = await redirect({
147
+ context,
121
148
  req,
122
- setCookie,
149
+ setRespCookie,
123
150
  growthbook,
124
- previousUrl: url,
151
+ previousUrl: requestUrl,
125
152
  resetDomChanges,
126
153
  setPreRedirectChangeIds: setPreRedirectChangeIds,
127
154
  });
155
+ originUrl = getOriginUrl(context, redirectRequestUrl);
128
156
 
129
- const originUrl = getOriginUrl(context as Context<unknown, unknown>, url);
157
+ // Pre-origin-fetch hook (after redirect logic):
158
+ hookResp = await context?.hooks?.onBeforeOriginFetch?.({ context, req, res, next, requestUrl, redirectRequestUrl, originUrl, route, attributes, growthbook });
159
+ if (hookResp) return hookResp;
130
160
 
131
- /* eslint-disable @typescript-eslint/no-explicit-any */
132
- let fetchedResponse:
133
- | (Res & { status: number; headers: Record<string, any>; text: any })
134
- | undefined = undefined;
161
+ /**
162
+ * 6. Fetch from origin, parse body / DOM
163
+ */
135
164
  try {
136
- fetchedResponse = (await context.helpers.fetch?.(
137
- context as Context<Req, Res>,
165
+ originResponse = await context.helpers.fetch(
166
+ context,
138
167
  originUrl,
139
- /* eslint-disable @typescript-eslint/no-explicit-any */
140
- )) as Res & { status: number; headers: Record<string, any>; text: any };
141
- const status = parseInt(fetchedResponse.status ? fetchedResponse.status + "" : "400");
142
- if (status >= 500) {
143
- console.error("Fetch: 5xx status returned");
144
- return context.helpers.sendResponse?.(
145
- context,
146
- res,
147
- headers,
148
- "Error fetching page",
149
- cookies,
150
- 500,
151
- );
152
- }
153
- if (status >= 400) {
154
- return context.helpers.proxyRequest?.(context, req, res, next);
155
- }
168
+ req,
169
+ ) as OriginResponse & Res;
156
170
  } catch (e) {
157
171
  console.error(e);
158
- return context.helpers.sendResponse?.(
159
- context,
160
- res,
161
- headers,
162
- "Error fetching page",
163
- cookies,
164
- 500,
165
- );
166
172
  }
167
- if (context.config.forwardProxyHeaders && fetchedResponse?.headers) {
168
- headers = { ...fetchedResponse.headers, ...headers };
173
+ const originStatus = originResponse ? parseInt(originResponse.status ? originResponse.status + "" : "400") : 500;
174
+
175
+ // On fetch hook (for custom response processing, etc)
176
+ hookResp = await context?.hooks?.onOriginFetch?.({ context, req, res, next, requestUrl, redirectRequestUrl, originUrl, route, attributes, growthbook, originResponse, originStatus });
177
+ if (hookResp) return hookResp;
178
+
179
+ // Standard error response handling
180
+ if (originStatus >= 500 || !originResponse) {
181
+ console.error("Fetch: 5xx status returned");
182
+ return context.helpers.sendResponse(context, res, {}, "Error fetching page", {}, 500);
183
+ }
184
+ if (originStatus >= 400) {
185
+ return originResponse;
186
+ }
187
+
188
+ // Got a valid response, begin processing
189
+ const originHeaders = headersToObject(originResponse.headers);
190
+ if (context.config.forwardProxyHeaders) {
191
+ resHeaders = { ...originHeaders, ...resHeaders };
192
+ }
193
+ // At minimum, the content-type is forwarded
194
+ resHeaders["content-type"] = originHeaders?.["content-type"];
195
+
196
+ if (context.config.useDefaultContentType && !resHeaders["content-type"]) {
197
+ resHeaders["content-type"] = "text/html";
198
+ }
199
+ if (context.config.processTextHtmlOnly && !(resHeaders["content-type"] ?? "").includes("text/html")) {
200
+ return context.helpers.proxyRequest(context, req, res, next);
169
201
  }
170
- body = await fetchedResponse.text();
171
202
 
172
- body = await applyDomMutations({
203
+ const { csp, nonce } = getCspInfo(context);
204
+ if (csp) {
205
+ resHeaders["content-security-policy"] = csp;
206
+ }
207
+
208
+ let body: string = "";
209
+ try {
210
+ // Check if content-encoding is gzip
211
+ if (originHeaders["content-encoding"] === "gzip") {
212
+ const buffer = await originResponse.arrayBuffer();
213
+ body = pako.inflate(new Uint8Array(buffer), { to: "string" });
214
+ delete resHeaders["content-encoding"]; // do not forward this header since it's now unzipped
215
+ } else {
216
+ body = await originResponse?.text() ?? "";
217
+ }
218
+ } catch(e) {
219
+ console.error(e);
220
+ }
221
+ let setBody = (s: string) => {
222
+ body = s;
223
+ }
224
+
225
+ let root: HTMLElement | undefined;
226
+ if (context.config.alwaysParseDOM) {
227
+ root = parse(body);
228
+ }
229
+
230
+ // Body ready hook (pre-DOM-mutations):
231
+ hookResp = await context?.hooks?.onBodyReady?.({ context, req, res, next, requestUrl, redirectRequestUrl, originUrl, route, attributes, growthbook, originResponse, originStatus, originHeaders, resHeaders, body, setBody, root });
232
+ if (hookResp) return hookResp;
233
+
234
+ /**
235
+ * 7. Apply visual editor DOM mutations
236
+ */
237
+ await applyDomMutations({
173
238
  body,
239
+ setBody,
240
+ root,
174
241
  nonce,
175
242
  domChanges,
176
243
  });
177
244
 
178
- body = injectScript({
179
- context: context as Context<unknown, unknown>,
245
+ /**
246
+ * 8. Inject the client-facing GrowthBook SDK (auto-wrapper)
247
+ */
248
+ injectScript({
249
+ context,
180
250
  body,
251
+ setBody,
181
252
  nonce,
182
253
  growthbook,
183
254
  attributes,
184
255
  preRedirectChangeIds,
185
- url,
186
- oldUrl,
256
+ url: redirectRequestUrl,
257
+ oldUrl: requestUrl,
187
258
  });
188
259
 
189
- return context.helpers.sendResponse?.(context, res, headers, body, cookies);
260
+ // Final hook (post-mutations) before sending back
261
+ hookResp = await context?.hooks?.onBeforeResponse?.({ context, req, res, next, requestUrl, redirectRequestUrl, originUrl, route, attributes, growthbook, originResponse, originStatus, originHeaders, resHeaders, body, setBody });
262
+ if (hookResp) return hookResp;
263
+
264
+ /**
265
+ * 9. Send mutated response
266
+ */
267
+ return context.helpers.sendResponse(context, res, resHeaders, body, respCookies);
190
268
  }
191
269
 
192
- export function getOriginUrl(context: Context, currentURL: string): string {
270
+
271
+ export function getOriginUrl<Req, Res>(context: Context<Req, Res>, currentURL: string): string {
193
272
  const proxyTarget = context.config.proxyTarget;
194
273
  const currentParsedURL = new URL(currentURL);
195
274
  const proxyParsedURL = new URL(proxyTarget);
@@ -220,3 +299,10 @@ export function getOriginUrl(context: Context, currentURL: string): string {
220
299
 
221
300
  return newURL;
222
301
  }
302
+
303
+ function headersToObject(headers: any) {
304
+ if (headers && typeof headers.entries === "function") {
305
+ return Object.fromEntries(headers.entries());
306
+ }
307
+ return headers || {};
308
+ }
package/src/attributes.ts CHANGED
@@ -6,7 +6,7 @@ export function getUserAttributes<Req, Res>(
6
6
  ctx: Context<Req, Res>,
7
7
  req: Req,
8
8
  url: string,
9
- setCookie: (key: string, value: string) => void,
9
+ setRespCookie: (key: string, value: string) => void,
10
10
  ): Attributes {
11
11
  const { config, helpers } = ctx;
12
12
 
@@ -20,7 +20,7 @@ export function getUserAttributes<Req, Res>(
20
20
  if (!helpers?.setCookie) {
21
21
  throw new Error("Missing required dependencies");
22
22
  }
23
- setCookie(config.uuidCookieName, uuid);
23
+ setRespCookie(config.uuidCookieName, uuid);
24
24
  }
25
25
 
26
26
  const autoAttributes = getAutoAttributes(ctx, req, url);
@@ -34,7 +34,6 @@ export function getUUID<Req, Res>(ctx: Context<Req, Res>, req: Req) {
34
34
  const { config, helpers } = ctx;
35
35
 
36
36
  const crypto = config?.crypto || globalThis?.crypto;
37
-
38
37
  if (!crypto || !helpers?.getCookie) {
39
38
  throw new Error("Missing required dependencies");
40
39
  }
package/src/config.ts CHANGED
@@ -1,13 +1,20 @@
1
1
  import { Config, Context, ExperimentRunEnvironment } from "./types";
2
2
 
3
- export const defaultContext: Context = {
3
+ type Req = any; // placeholder
4
+ type Res = any; // placeholder
5
+
6
+ export const defaultContext: Context<Req, Res> = {
4
7
  config: {
5
8
  proxyTarget: "/",
6
9
  forwardProxyHeaders: true,
10
+ followRedirects: true,
11
+ useDefaultContentType: false,
12
+ processTextHtmlOnly: true,
7
13
  environment: "production",
8
14
  maxPayloadSize: "2mb",
9
15
  runVisualEditorExperiments: "everywhere",
10
16
  disableJsInjection: false,
17
+ alwaysParseDOM: false,
11
18
  runUrlRedirectExperiments: "browser",
12
19
  runCrossOriginUrlRedirectExperiments: "browser",
13
20
  injectRedirectUrlScript: true,
@@ -24,12 +31,32 @@ export const defaultContext: Context = {
24
31
  uuidKey: "id",
25
32
  skipAutoAttributes: false,
26
33
  },
27
- helpers: {},
34
+ helpers: {
35
+ getRequestURL: function(req: Req): string {
36
+ throw new Error("getRequestURL not implemented");
37
+ },
38
+ getRequestMethod: function(req: Req): string {
39
+ throw new Error("getRequestMethod not implemented");
40
+ },
41
+ sendResponse: function(ctx: Context<Req, Res>, res?: any, headers?: Record<string, any> | undefined, body?: string | undefined, cookies?: Record<string, string> | undefined, status?: number | undefined): unknown {
42
+ throw new Error("sendResponse not implemented");
43
+ },
44
+ fetch: function(ctx: Context<Req, Res>, url: string, req: Req): Promise<Res> {
45
+ throw new Error("fetchFn not implemented");
46
+ },
47
+ proxyRequest: function(ctx: Context<Req, Res>, req: Req, res?: any, next?: any): Promise<unknown> {
48
+ throw new Error("proxyRequest not implemented");
49
+ }
50
+ },
51
+ hooks: {},
28
52
  };
29
53
 
30
54
  export interface ConfigEnv {
31
55
  PROXY_TARGET?: string;
32
56
  FORWARD_PROXY_HEADERS?: string;
57
+ FOLLOW_REDIRECTS?: string;
58
+ USE_DEFAULT_CONTENT_TYPE?: string;
59
+ PROCESS_TEXT_HTML_ONLY?: string;
33
60
  NODE_ENV?: string;
34
61
  MAX_PAYLOAD_SIZE?: string;
35
62
 
@@ -37,6 +64,7 @@ export interface ConfigEnv {
37
64
 
38
65
  RUN_VISUAL_EDITOR_EXPERIMENTS?: ExperimentRunEnvironment;
39
66
  DISABLE_JS_INJECTION?: string;
67
+ ALWAYS_PARSE_DOM?: string;
40
68
 
41
69
  RUN_URL_REDIRECT_EXPERIMENTS?: ExperimentRunEnvironment;
42
70
  RUN_CROSS_ORIGIN_URL_REDIRECT_EXPERIMENTS?: ExperimentRunEnvironment;
@@ -79,6 +107,15 @@ export function getConfig(env: ConfigEnv): Config {
79
107
  config.forwardProxyHeaders = ["true", "1"].includes(
80
108
  env.FORWARD_PROXY_HEADERS ?? "" + defaultContext.config.forwardProxyHeaders,
81
109
  );
110
+ config.followRedirects = ["true", "1"].includes(
111
+ env.FOLLOW_REDIRECTS ?? "" + defaultContext.config.followRedirects,
112
+ );
113
+ config.useDefaultContentType = ["true", "1"].includes(
114
+ env.USE_DEFAULT_CONTENT_TYPE ?? "" + defaultContext.config.useDefaultContentType,
115
+ );
116
+ config.processTextHtmlOnly = ["true", "1"].includes(
117
+ env.PROCESS_TEXT_HTML_ONLY ?? "" + defaultContext.config.processTextHtmlOnly,
118
+ );
82
119
  config.environment = env.NODE_ENV ?? defaultContext.config.environment;
83
120
  config.maxPayloadSize =
84
121
  env.MAX_PAYLOAD_SIZE ?? defaultContext.config.maxPayloadSize;
@@ -96,6 +133,9 @@ export function getConfig(env: ConfigEnv): Config {
96
133
  config.disableJsInjection = ["true", "1"].includes(
97
134
  env.DISABLE_JS_INJECTION ?? "" + defaultContext.config.disableJsInjection,
98
135
  );
136
+ config.alwaysParseDOM = ["true", "1"].includes(
137
+ env.ALWAYS_PARSE_DOM ?? "" + defaultContext.config.alwaysParseDOM,
138
+ );
99
139
 
100
140
  config.runUrlRedirectExperiments = (env.RUN_URL_REDIRECT_EXPERIMENTS ??
101
141
  defaultContext.config
@@ -1,23 +1,29 @@
1
1
  import { AutoExperimentVariation, DOMMutation } from "@growthbook/growthbook";
2
- import { parse } from "node-html-parser";
2
+ import { HTMLElement, parse } from "node-html-parser";
3
3
 
4
4
  export async function applyDomMutations({
5
5
  body,
6
+ setBody,
7
+ root,
6
8
  nonce,
7
9
  domChanges,
8
10
  }: {
9
11
  body: string;
12
+ setBody: (s: string) => void;
13
+ root?: HTMLElement;
10
14
  nonce?: string;
11
15
  domChanges: AutoExperimentVariation[];
12
16
  }) {
13
- if (!domChanges.length) return body;
17
+ if (!domChanges.length) return;
18
+ root = root ?? parse(body);
19
+ if (!root) return;
14
20
 
15
- const root = parse(body);
16
21
  const headEl = root.querySelector("head");
17
22
 
18
23
  domChanges.forEach(({ domMutations, css, js }) => {
19
24
  if (css) {
20
25
  const parentEl = headEl || root;
26
+ if (!parentEl) return;
21
27
  const el = parse(`<style>${css}</style>`);
22
28
  parentEl.appendChild(el);
23
29
  }
@@ -86,9 +92,11 @@ export async function applyDomMutations({
86
92
  });
87
93
 
88
94
  body = root.toString();
89
- return body;
95
+ setBody(body);
96
+ return;
90
97
 
91
98
  function html(selector: string, cb: (val: string) => string) {
99
+ if (!root) return;
92
100
  const els = root.querySelectorAll(selector);
93
101
  els.map((el) => {
94
102
  el.innerHTML = cb(el.innerHTML);
@@ -96,6 +104,7 @@ export async function applyDomMutations({
96
104
  }
97
105
 
98
106
  function classes(selector: string, cb: (val: Set<string>) => void) {
107
+ if (!root) return;
99
108
  const els = root.querySelectorAll(selector);
100
109
  els.map((el) => {
101
110
  const classList = new Set(el.classNames);
@@ -109,6 +118,7 @@ export async function applyDomMutations({
109
118
  attr: string,
110
119
  cb: (val: string | null) => string | null,
111
120
  ) {
121
+ if (!root) return;
112
122
  const validAttributeName = /^[a-zA-Z:_][a-zA-Z0-9:_.-]*$/;
113
123
  if (!validAttributeName.test(attr)) {
114
124
  return;
@@ -139,8 +149,10 @@ export async function applyDomMutations({
139
149
  selector: string,
140
150
  cb: () => { insertBeforeSelector?: string; parentSelector: string },
141
151
  ) {
152
+ if (!root) return;
142
153
  const els = root.querySelectorAll(selector);
143
154
  els.map((el) => {
155
+ if (!root) return;
144
156
  const { insertBeforeSelector, parentSelector } = cb();
145
157
  const parent = root.querySelector(parentSelector);
146
158
  const insertBefore = insertBeforeSelector