Implement gateway proxy service
This commit is contained in:
@@ -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
61
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
17
src/config/env.ts
Normal 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",
|
||||||
|
};
|
||||||
46
src/index.ts
46
src/index.ts
@@ -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
42
src/lib/httpClient.ts
Normal 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
37
src/routes/proxy.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user