Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ jobs:
- name: Lint and Type-check
run: |
# Linting
# Temporarily disabled due to code drift while this script wasn't running
# pnpm lint
pnpm lint:ci

# Typescript type checks
pnpm type-check
Expand Down
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pnpm lint-staged
14 changes: 10 additions & 4 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import js from "@eslint/js";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import importPlugin from "eslint-plugin-import";
import prettierRecommended from "eslint-plugin-prettier/recommended";
import eslintConfigPrettier from "eslint-config-prettier";
import globals from "globals";

export default [
{
ignores: ["**/dist/**", "**/coverage/**", "docs/**"],
ignores: [
"**/dist/**",
"**/coverage/**",
"docs/**",
"**/scripts/**",
"**/generated/**",
],
},
js.configs.recommended,
{
Expand Down Expand Up @@ -75,6 +81,6 @@ export default [
],
},
},
// Must be last — disables rules that conflict with Prettier
prettierRecommended,
// Must be last — disables ESLint rules that conflict with Prettier
eslintConfigPrettier,
];
13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"license": "MIT",
"author": "Bryce Fitzsimons",
"scripts": {
"prepare": "husky",
"lint": "eslint './**/*.{ts,tsx,js,jsx}' --fix --max-warnings 0 --cache --cache-strategy content",
"lint:ci": "eslint './**/*.{ts,tsx,js,jsx}' --max-warnings 0 --cache --cache-strategy content",
"build:clean": "pnpm -r build:clean",
"build:deps": "pnpm --filter @growthbook/proxy-eval --filter @growthbook/edge-utils build",
"build:deps:proxy": "pnpm --filter @growthbook/proxy-eval build",
Expand All @@ -27,16 +29,25 @@
"dependencies": {
"pm2": "^6.0.14"
},
"lint-staged": {
"./**/*.{json,md,yaml,yml}": [
"prettier --write"
],
"./**/*.{ts,tsx,js,jsx}": [
"eslint --fix --max-warnings 0 --cache --cache-strategy content"
]
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"globals": "^14.0.0",
"@typescript-eslint/parser": "^8.31.1",
"eslint": "^9.25.1",
"eslint-config-prettier": "^10.1.2",
"eslint-import-resolver-typescript": "^4.3.4",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.2.6",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
}
Expand Down
32 changes: 19 additions & 13 deletions packages/apps/proxy/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { Express } from "express";
import cors from "cors";
import packageJson from "../package.json";
import { adminRouter } from "./controllers/adminController";
import { eventStreamRouter } from "./controllers/eventStreamController";
import { featuresRouter } from "./controllers/featuresController";
import proxyMiddleware from "./middleware/proxyMiddleware";
import { featuresCache, initializeCache, cacheRefreshScheduler } from "./services/cache";
import {
featuresCache,
initializeCache,
cacheRefreshScheduler,
} from "./services/cache";
import { initializeRegistrar, registrar } from "./services/registrar";
import {
eventStreamManager,
Expand All @@ -17,7 +22,6 @@ import { healthRouter } from "./controllers/healthController";

export { Context, GrowthBookProxy, CacheEngine } from "./types";

const packageJson = require("../package.json");
export const version = (packageJson.version ?? "unknown") + "";

const defaultContext: Context = {
Expand Down Expand Up @@ -56,22 +60,24 @@ export const growthBookProxy = async (
// initialize
initializeLogger(ctx);
await initializeRegistrar(ctx);
ctx.enableCache && (await initializeCache(ctx));
ctx.enableRemoteEval &&
ctx.enableStickyBucketing &&
(await initializeStickyBucketService(ctx));
ctx.enableEventStream && initializeEventStreamManager(ctx);
if (ctx.enableCache) await initializeCache(ctx);
if (ctx.enableRemoteEval && ctx.enableStickyBucketing) {
await initializeStickyBucketService(ctx);
}
if (ctx.enableEventStream) initializeEventStreamManager(ctx);

// set up handlers
ctx.enableCors && app.use(cors());
ctx.enableHealthCheck && app.use("/healthcheck", healthRouter);
ctx.enableAdmin && logger.warn({ enableAdmin: ctx.enableAdmin }, "Admin API is enabled");
ctx.enableAdmin && app.use("/admin", adminRouter);
if (ctx.enableCors) app.use(cors());
if (ctx.enableHealthCheck) app.use("/healthcheck", healthRouter);
if (ctx.enableAdmin) {
logger.warn({ enableAdmin: ctx.enableAdmin }, "Admin API is enabled");
app.use("/admin", adminRouter);
}

ctx.enableEventStream && app.use("/sub", eventStreamRouter);
if (ctx.enableEventStream) app.use("/sub", eventStreamRouter);
app.use("/", featuresRouter(ctx));

ctx.proxyAllRequests && app.all("/*", proxyMiddleware);
if (ctx.proxyAllRequests) app.all("/*", proxyMiddleware);

return {
app,
Expand Down
5 changes: 4 additions & 1 deletion packages/apps/proxy/src/controllers/adminController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ const getAllConnections = (req: Request, res: Response) => {
return res.status(200).json(data);
};

const deleteConnection = (req: Request<ConnectionApiKeyParams>, res: Response) => {
const deleteConnection = (
req: Request<ConnectionApiKeyParams>,
res: Response,
) => {
const apiKey = req.params.apiKey;
if (!apiKey) {
return res.status(400).json({ message: "API key required" });
Expand Down
16 changes: 10 additions & 6 deletions packages/apps/proxy/src/controllers/featuresController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express, { NextFunction, Request, Response } from "express";
import { evaluateFeatures } from "@growthbook/proxy-eval";
import { z } from "zod";
import readThroughCacheMiddleware from "../middleware/cache/readThroughCacheMiddleware";
import { featuresCache } from "../services/cache";
import { stickyBucketService } from "../services/stickyBucket";
Expand All @@ -13,7 +14,6 @@ import logger from "../services/logger";
import { fetchFeatures } from "../services/features";
import { Context } from "../types";
import { MAX_PAYLOAD_SIZE } from "../init";
import { z } from "zod";

const getFeatures = async (req: Request, res: Response, next: NextFunction) => {
if (!registrar?.growthbookApiHost) {
Expand Down Expand Up @@ -70,7 +70,7 @@ const getFeatures = async (req: Request, res: Response, next: NextFunction) => {
});
}

featuresCache && logger.debug("cache HIT");
if (featuresCache) logger.debug("cache HIT");
return res.status(200).json(payload);
};

Expand Down Expand Up @@ -142,8 +142,7 @@ const getEvaluatedFeatures = async (req: Request, res: Response) => {
});
}

featuresCache && logger.debug("cache HIT");

if (featuresCache) logger.debug("cache HIT");

const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
Expand All @@ -152,8 +151,13 @@ const getEvaluatedFeatures = async (req: Request, res: Response) => {
error: "Invalid input",
});
}
const { attributes = {}, forcedVariations = {}, forcedFeatures = [], url = "" } = parsedBody.data;
let forcedFeaturesMap: Map<string, any> = new Map();
const {
attributes = {},
forcedVariations = {},
forcedFeatures = [],
url = "",
} = parsedBody.data;
let forcedFeaturesMap: Map<string, unknown> = new Map();
try {
if (Object.keys(attributes).length > 1000) {
throw new Error("Max attribute keys");
Expand Down
9 changes: 5 additions & 4 deletions packages/apps/proxy/src/controllers/healthController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import express, { Request, Response } from "express";
import path from "path";
import fs from "fs";
import express, { Request, Response } from "express";
import { Context, version } from "../app";
import { registrar } from "../services/registrar";
import { featuresCache } from "../services/cache";
Expand All @@ -27,18 +27,19 @@ function getBuild() {
}

async function getChecks(ctx: Context) {
const checks: Record<string, any> = {
const checks: Record<string, unknown> = {
apiServer: "down",
registrar: registrar.status,
};
const cacheType = ctx?.cacheSettings?.cacheEngine || "memory";
checks[`cache:${cacheType}`] = await featuresCache?.getStatus?.() || "pending";
checks[`cache:${cacheType}`] =
(await featuresCache?.getStatus?.()) || "pending";

try {
const resp = await fetch(ctx.growthbookApiHost + "/healthcheck");
const data = await resp.json();
if (data?.healthy) checks.apiServer = "up";
} catch(e) {
} catch (e) {
console.error("healthcheck API sever error", e);
}
return checks;
Expand Down
17 changes: 12 additions & 5 deletions packages/apps/proxy/src/init.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import express from "express";
import * as spdy from "spdy";
import dotenv from "dotenv";
import { CacheEngine, Context, StickyBucketEngine, CacheRefreshStrategy } from "./types";
import {
CacheEngine,
Context,
StickyBucketEngine,
CacheRefreshStrategy,
} from "./types";
dotenv.config({ path: "./.env.local" });

export const MAX_PAYLOAD_SIZE = "2mb";
Expand Down Expand Up @@ -35,9 +40,10 @@ export default async () => {
cacheSettings: {
cacheEngine: (process.env.CACHE_ENGINE || "memory") as CacheEngine,
staleTTL: parseInt(process.env.CACHE_STALE_TTL || "60"),
expiresTTL: process.env.CACHE_EXPIRES_TTL === "never"
? "never"
: parseInt(process.env.CACHE_EXPIRES_TTL || "3600"),
expiresTTL:
process.env.CACHE_EXPIRES_TTL === "never"
? "never"
: parseInt(process.env.CACHE_EXPIRES_TTL || "3600"),
allowStale: ["true", "1"].includes(process.env.CACHE_ALLOW_STALE ?? "1"),
cacheRefreshStrategy: (process.env.CACHE_REFRESH_STRATEGY ||
"schedule") as CacheRefreshStrategy,
Expand All @@ -60,7 +66,8 @@ export default async () => {
: undefined,
// Redis only - sentinel:
useSentinel: ["true", "1"].includes(process.env.USE_SENTINEL ?? "0"),
sentinelConnectionOptionsJSON: process.env.SENTINEL_CONNECTION_OPTIONS_JSON
sentinelConnectionOptionsJSON: process.env
.SENTINEL_CONNECTION_OPTIONS_JSON
? JSON.parse(process.env.SENTINEL_CONNECTION_OPTIONS_JSON)
: undefined,
},
Expand Down
12 changes: 7 additions & 5 deletions packages/apps/proxy/src/middleware/apiKeyMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Request, Response, NextFunction } from "express";
import { Request, Response, NextFunction, RequestHandler } from "express";
import { Context } from "../types";
import logger from "../services/logger";

Expand All @@ -8,22 +8,24 @@ const RE_API_KEY = /(?:api|sub|eval)\/.*?\/?([^/?]*)\/?(?:\?.*)?$/;
* Extracts the API key from the request path or header.
* Calls next() or returns 401 if no API key is present.
**/
export const apiKeyMiddleware = (
export const apiKeyMiddleware: RequestHandler = (
req: Request,
res: Response,
next: NextFunction,
) => {
const ctx = req.app.locals?.ctx as Context;
ctx?.verboseDebugging && logger.info("apiKeyMiddleware");
if (ctx?.verboseDebugging) logger.info("apiKeyMiddleware");

const apiKey =
req.headers?.["x-growthbook-api-key"] ||
req.originalUrl.match(RE_API_KEY)?.[1];
if (!apiKey) {
ctx?.verboseDebugging && logger.warn({ path: req.originalUrl }, "API key required");
if (ctx?.verboseDebugging) {
logger.warn({ path: req.originalUrl }, "API key required");
}
return res.status(401).json({ message: "API key required" });
}
ctx?.verboseDebugging && logger.info({ apiKey }, "API key extracted");
if (ctx?.verboseDebugging) logger.info({ apiKey }, "API key extracted");
res.locals.apiKey = apiKey;
next();
};
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,18 @@ export default async ({ proxyTarget }: { proxyTarget: string }) => {
error: (err, req, res) => {
logger.error({ err }, "proxy error");
errorCounts[proxyTarget] = (errorCounts[proxyTarget] || 0) + 1;
if ((res as ServerResponse)?.writeHead && !(res as ServerResponse).headersSent) {
(res as ServerResponse)?.writeHead(500, { 'Content-Type': 'application/json' });
if (
(res as ServerResponse)?.writeHead &&
!(res as ServerResponse).headersSent
) {
(res as ServerResponse)?.writeHead(500, {
"Content-Type": "application/json",
});
}
res?.end(
JSON.stringify({
message: 'Proxy error',
})
message: "Proxy error",
}),
);
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { NextFunction, Request, Response } from "express";
import { NextFunction, Request, RequestHandler, Response } from "express";
import { eventStreamManager } from "../../services/eventStreamManager";
import { featuresCache } from "../../services/cache";
import { Context } from "../../types";
import logger from "../../services/logger";
import { registrar } from "../../services/registrar";

export const broadcastEventStreamMiddleware = async (
export const broadcastEventStreamMiddleware: RequestHandler = async (
req: Request,
res: Response,
next: NextFunction,
) => {
const ctx = req.app.locals?.ctx as Context;
ctx?.verboseDebugging && logger.info("broadcastEventStreamMiddleware");
if (ctx?.verboseDebugging) logger.info("broadcastEventStreamMiddleware");

if (ctx?.enableEventStream && eventStreamManager) {
const apiKey = res.locals.apiKey;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import { NextFunction, Request, Response } from "express";
import { NextFunction, Request, RequestHandler, Response } from "express";
import { registrar } from "../../services/registrar";
import { Context } from "../../types";
import logger from "../../services/logger";

export const validateEventStreamChannelMiddleware = (
export const validateEventStreamChannelMiddleware: RequestHandler = (
req: Request,
res: Response,
next: NextFunction,
) => {
const ctx = req.app.locals?.ctx as Context;
ctx?.verboseDebugging && logger.info("validateEventStreamChannelMiddleware");
if (ctx?.verboseDebugging)
logger.info("validateEventStreamChannelMiddleware");

if (ctx?.enableEventStream) {
const apiKey = res.locals.apiKey;
const validApiKeys = Object.keys(registrar.getAllConnections());
if (!validApiKeys.includes(apiKey)) {
ctx?.verboseDebugging &&
if (ctx?.verboseDebugging) {
logger.warn({ validApiKeys, apiKey }, "No channel found");
}
return res.status(400).json({ message: "No channel found" });
}
}
Expand Down
Loading
Loading