Implement gateway proxy service

This commit is contained in:
Alexa Amundson
2025-11-21 00:02:48 -06:00
parent 7c8a22ef50
commit 8eef3b9366
7 changed files with 173 additions and 36 deletions

View File

@@ -14,4 +14,4 @@ RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
ENV PORT=8080 ENV PORT=8080
EXPOSE 8080 EXPOSE 8080
CMD ["npm", "start"] CMD ["node", "dist/index.js"]

61
package-lock.json generated
View File

@@ -1,14 +1,16 @@
{ {
"name": "blackroad-os-public-api", "name": "blackroad-os-public-api",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "blackroad-os-public-api", "name": "blackroad-os-public-api",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"axios": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2" "express": "^4.19.2"
}, },
"devDependencies": { "devDependencies": {
@@ -1466,9 +1468,19 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -1938,7 +1950,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
@@ -2109,7 +2120,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@@ -2175,6 +2185,18 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2285,7 +2307,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -2511,11 +2532,30 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
@@ -2775,7 +2815,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
@@ -4357,6 +4396,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pure-rand": { "node_modules/pure-rand": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "blackroad-os-public-api", "name": "blackroad-os-public-api",
"version": "0.1.0", "version": "0.2.0",
"description": "BlackRoad OS Public API gateway service", "description": "BlackRoad OS Public API gateway service",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
@@ -10,7 +10,9 @@
"test": "NODE_ENV=test jest" "test": "NODE_ENV=test jest"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2" "express": "^4.19.2"
}, },
"devDependencies": { "devDependencies": {

17
src/config/env.ts Normal file
View File

@@ -0,0 +1,17 @@
import dotenv from "dotenv";
dotenv.config();
const parsePort = (value: string | undefined, fallback: number): number => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
export const env = {
PORT: parsePort(process.env.PORT, 8080),
CORE_BASE_URL: process.env.CORE_BASE_URL || "http://localhost:3001",
AGENTS_BASE_URL: process.env.AGENTS_BASE_URL || "http://localhost:3002",
OPERATOR_BASE_URL: process.env.OPERATOR_BASE_URL || "http://localhost:3003",
SERVICE_VERSION:
process.env.SERVICE_VERSION || process.env.npm_package_version || "dev",
};

View File

@@ -1,38 +1,32 @@
import express from "express"; import express from "express";
import cors from "cors"; import { env } from "./config/env";
import healthRouter from "./routes/health"; import { createProxyRouter } from "./routes/proxy";
import infoRouter from "./routes/info"; import { serviceClients } from "./lib/httpClient";
import versionRouter from "./routes/version";
import debugEnvRouter from "./routes/debugEnv";
import v1PingRouter from "./routes/v1/ping";
import { loggingMiddleware } from "./middleware/logging";
import { errorHandler } from "./middleware/errorHandler";
import { SERVICE_ID } from "./config/serviceConfig";
const app = express(); const app = express();
app.use(cors()); app.use(express.json({ limit: "5mb" }));
app.use(express.json());
app.use(loggingMiddleware);
app.use(healthRouter); app.get("/health", (_req, res) => {
app.use(infoRouter); res.json({ status: "ok" });
app.use(versionRouter); });
app.use(debugEnvRouter);
app.use("/v1", v1PingRouter);
app.use(errorHandler); app.get("/version", (_req, res) => {
res.json({ version: env.SERVICE_VERSION });
});
const PORT = process.env.PORT ? Number(process.env.PORT) : 8080; app.use("/core", createProxyRouter(serviceClients.core));
app.use("/agents", createProxyRouter(serviceClients.agents));
app.use("/operator", createProxyRouter(serviceClients.operator));
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(502).json({ error: "Upstream request failed" });
});
if (process.env.NODE_ENV !== "test") { if (process.env.NODE_ENV !== "test") {
app.listen(PORT, () => { app.listen(env.PORT, () => {
console.log( console.log(`Gateway listening on port ${env.PORT}`);
JSON.stringify({
ts: new Date().toISOString(),
message: `Service ${SERVICE_ID} listening on port ${PORT}`,
})
);
}); });
} }

42
src/lib/httpClient.ts Normal file
View File

@@ -0,0 +1,42 @@
import axios, { AxiosInstance, AxiosRequestConfig, Method } from "axios";
import type { Request } from "express";
import { env } from "../config/env";
type TargetService = "core" | "agents" | "operator";
const DEFAULT_TIMEOUT_MS = 15_000;
const createClient = (baseURL: string): AxiosInstance =>
axios.create({
baseURL,
timeout: DEFAULT_TIMEOUT_MS,
});
export const serviceClients: Record<TargetService, AxiosInstance> = {
core: createClient(env.CORE_BASE_URL),
agents: createClient(env.AGENTS_BASE_URL),
operator: createClient(env.OPERATOR_BASE_URL),
};
export const forwardRequest = async (
client: AxiosInstance,
req: Request
) => {
const method = req.method.toUpperCase() as Method;
const config: AxiosRequestConfig = {
url: req.originalUrl.replace(/^\/[a-z]+/, ""),
method,
params: req.query,
data: req.body,
headers: {
...req.headers,
host: undefined,
connection: undefined,
"content-length": undefined,
},
responseType: "arraybuffer",
validateStatus: () => true,
};
return client.request(config);
};

37
src/routes/proxy.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Router } from "express";
import { AxiosInstance } from "axios";
import { forwardRequest } from "../lib/httpClient";
const mapProxyHeaders = (headers: Record<string, any>) => {
const excluded = new Set([
"transfer-encoding",
"content-encoding",
"content-length",
"connection",
]);
return Object.entries(headers).reduce<Record<string, string>>((acc, [key, value]) => {
if (!excluded.has(key.toLowerCase()) && typeof value === "string") {
acc[key] = value;
}
return acc;
}, {});
};
export const createProxyRouter = (client: AxiosInstance) => {
const router = Router();
router.use(async (req, res, next) => {
try {
const response = await forwardRequest(client, req);
res
.status(response.status)
.set(mapProxyHeaders(response.headers))
.send(response.data);
} catch (error) {
next(error);
}
});
return router;
};