@derian-cordoba/api-gateway 1.0.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/.env.example +32 -0
- package/README.md +382 -0
- package/dist/src/apps/api-gateway/App.d.ts +12 -0
- package/dist/src/apps/api-gateway/App.js +35 -0
- package/dist/src/apps/api-gateway/Server.d.ts +27 -0
- package/dist/src/apps/api-gateway/Server.js +79 -0
- package/dist/src/apps/api-gateway/config/app-env.d.ts +11 -0
- package/dist/src/apps/api-gateway/config/app-env.js +13 -0
- package/dist/src/apps/api-gateway/config/cors/config.d.ts +6 -0
- package/dist/src/apps/api-gateway/config/cors/config.js +13 -0
- package/dist/src/apps/api-gateway/config/env/config.d.ts +4 -0
- package/dist/src/apps/api-gateway/config/env/config.js +6 -0
- package/dist/src/apps/api-gateway/config/gateway/config.d.ts +9 -0
- package/dist/src/apps/api-gateway/config/gateway/config.js +9 -0
- package/dist/src/apps/api-gateway/config/routes/config.d.ts +4 -0
- package/dist/src/apps/api-gateway/config/routes/config.js +6 -0
- package/dist/src/apps/api-gateway/index.d.ts +2 -0
- package/dist/src/apps/api-gateway/index.js +47 -0
- package/dist/src/apps/api-gateway/logger.d.ts +2 -0
- package/dist/src/apps/api-gateway/logger.js +18 -0
- package/dist/src/apps/api-gateway/routes/HealthRouter.d.ts +2 -0
- package/dist/src/apps/api-gateway/routes/HealthRouter.js +18 -0
- package/dist/src/apps/api-gateway/routes/ProxyManager.d.ts +13 -0
- package/dist/src/apps/api-gateway/routes/ProxyManager.js +97 -0
- package/dist/src/apps/api-gateway/routes/RouteValidator.d.ts +21 -0
- package/dist/src/apps/api-gateway/routes/RouteValidator.js +36 -0
- package/dist/src/apps/api-gateway/routes/Router.d.ts +18 -0
- package/dist/src/apps/api-gateway/routes/Router.js +120 -0
- package/package.json +70 -0
package/.env.example
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# ── Server ───────────────────────────────────────────────────────────────────
|
|
2
|
+
# Port the gateway listens on. GATEWAY_PORT takes priority over PORT.
|
|
3
|
+
GATEWAY_PORT=3000
|
|
4
|
+
|
|
5
|
+
# Optional URL prefix mounted before all gateway routes.
|
|
6
|
+
# Example: GATEWAY_PREFIX=/api/v1 → routes become /api/v1/<baseURL>
|
|
7
|
+
GATEWAY_PREFIX=
|
|
8
|
+
|
|
9
|
+
# ── Logging ──────────────────────────────────────────────────────────────────
|
|
10
|
+
# Log level: trace | debug | info | warn | error | fatal
|
|
11
|
+
LOG_LEVEL=info
|
|
12
|
+
|
|
13
|
+
# Set to "production" to disable pino-pretty and emit newline-delimited JSON.
|
|
14
|
+
NODE_ENV=development
|
|
15
|
+
|
|
16
|
+
# ── CORS ─────────────────────────────────────────────────────────────────────
|
|
17
|
+
# Comma-separated list of allowed origins. Use * to allow all origins.
|
|
18
|
+
CORS_ORIGINS=*
|
|
19
|
+
|
|
20
|
+
# Comma-separated list of allowed HTTP methods.
|
|
21
|
+
CORS_METHODS=GET,POST,PUT,DELETE,PATCH,OPTIONS
|
|
22
|
+
|
|
23
|
+
# Comma-separated list of allowed request headers.
|
|
24
|
+
CORS_HEADERS=Content-Type,Authorization
|
|
25
|
+
|
|
26
|
+
# ── Routes ───────────────────────────────────────────────────────────────────
|
|
27
|
+
# Path to the JSON file that defines proxy routes (relative to process cwd).
|
|
28
|
+
ROUTES_FILE_PATH=routes.json
|
|
29
|
+
|
|
30
|
+
# Inline route definitions as a JSON array (merged with ROUTES_FILE_PATH).
|
|
31
|
+
# Example: ROUTES=[{"baseURL":"/svc","proxy":{"target":"http://localhost:4000","changeOrigin":true}}]
|
|
32
|
+
ROUTES=
|
package/README.md
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# API Gateway
|
|
2
|
+
|
|
3
|
+
A generic, configuration-driven HTTP API gateway. Routes incoming requests to upstream services via a JSON config file or environment variable, with per-route rate limiting, request validation, structured logging, and full security headers out of the box.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Features](#features)
|
|
10
|
+
- [Requirements](#requirements)
|
|
11
|
+
- [Getting Started](#getting-started)
|
|
12
|
+
- [Configuration](#configuration)
|
|
13
|
+
- [Environment Variables](#environment-variables)
|
|
14
|
+
- [Route Configuration](#route-configuration)
|
|
15
|
+
- [Running the Gateway](#running-the-gateway)
|
|
16
|
+
- [Health Check](#health-check)
|
|
17
|
+
- [Project Structure](#project-structure)
|
|
18
|
+
- [Example Project](#example-project)
|
|
19
|
+
- [Architecture](#architecture)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
- **Configuration-driven routing** — define proxy routes in a JSON file, an environment variable, or both; changes take effect on restart with zero code changes
|
|
26
|
+
- **Per-route rate limiting** — each route can declare its own `max` requests / `windowMs` window, enforced by `express-rate-limit`
|
|
27
|
+
- **Startup validation** — route config is validated with Zod at boot time; the process exits with a descriptive error rather than silently misbehaving
|
|
28
|
+
- **Structured logging** — `pino` + `pino-http` emit newline-delimited JSON in production and human-readable output (via `pino-pretty`) in development
|
|
29
|
+
- **Security headers** — full `helmet` defaults applied to every response (`CSP`, `HSTS`, `X-Frame-Options`, `X-Content-Type-Options`, etc.)
|
|
30
|
+
- **Configurable CORS** — origins, methods, and allowed headers controlled via environment variables
|
|
31
|
+
- **Health check endpoint** — `GET /health` returns uptime, version, and timestamp; always available regardless of configured routes
|
|
32
|
+
- **Optional URL prefix** — mount all routes under a shared prefix (e.g. `/api/v1`) via `GATEWAY_PREFIX`
|
|
33
|
+
- **Body forwarding** — JSON bodies on `POST`, `PUT`, and `PATCH` requests are correctly forwarded to upstreams (`fixRequestBody`)
|
|
34
|
+
- **Graceful shutdown** — `SIGINT` and `uncaughtException` handlers stop the server cleanly before exiting
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Node.js 18+
|
|
41
|
+
- pnpm 10+
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Getting Started
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# 1. Clone and install
|
|
49
|
+
git clone <repo-url>
|
|
50
|
+
cd api-gateway
|
|
51
|
+
pnpm install
|
|
52
|
+
|
|
53
|
+
# 2. Create your env file
|
|
54
|
+
cp .env.example .env
|
|
55
|
+
|
|
56
|
+
# 3. Create a routes config (see Route Configuration below)
|
|
57
|
+
cp examples/basic/routes.json routes.json # or write your own
|
|
58
|
+
|
|
59
|
+
# 4. Start in development mode (hot-reload)
|
|
60
|
+
pnpm dev
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
### Environment Variables
|
|
68
|
+
|
|
69
|
+
Copy `.env.example` to `.env` and edit as needed.
|
|
70
|
+
|
|
71
|
+
#### Server
|
|
72
|
+
|
|
73
|
+
| Variable | Default | Description |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `GATEWAY_PORT` | `3000` | Port the gateway listens on. Takes priority over `PORT`. |
|
|
76
|
+
| `PORT` | `3000` | Fallback port when `GATEWAY_PORT` is not set. |
|
|
77
|
+
| `GATEWAY_PREFIX` | _(none)_ | Optional path prefix for all routes. Example: `/api/v1` makes proxy routes reachable at `/api/v1/<baseURL>` and the health check at `/api/v1/health`. |
|
|
78
|
+
|
|
79
|
+
#### Logging
|
|
80
|
+
|
|
81
|
+
| Variable | Default | Description |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `NODE_ENV` | `development` | Set to `production` to disable `pino-pretty` and emit newline-delimited JSON. |
|
|
84
|
+
| `LOG_LEVEL` | `info` | Pino log level: `trace` · `debug` · `info` · `warn` · `error` · `fatal`. |
|
|
85
|
+
|
|
86
|
+
#### CORS
|
|
87
|
+
|
|
88
|
+
| Variable | Default | Description |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `CORS_ORIGINS` | `*` | Comma-separated list of allowed origins. Use `*` to allow all. |
|
|
91
|
+
| `CORS_METHODS` | `GET,POST,PUT,DELETE,PATCH,OPTIONS` | Comma-separated list of allowed HTTP methods. |
|
|
92
|
+
| `CORS_HEADERS` | `Content-Type,Authorization` | Comma-separated list of allowed request headers. |
|
|
93
|
+
|
|
94
|
+
#### Routes
|
|
95
|
+
|
|
96
|
+
| Variable | Default | Description |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `ROUTES_FILE_PATH` | `routes.json` | Path to the JSON route config file, relative to `process.cwd()`. |
|
|
99
|
+
| `ROUTES` | _(none)_ | Inline route definitions as a JSON array. Merged with `ROUTES_FILE_PATH`. Useful for containerised deployments where injecting a file is inconvenient. |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
### Route Configuration
|
|
104
|
+
|
|
105
|
+
Routes are defined as a JSON array. Each entry is a **Gateway** object:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
{
|
|
109
|
+
baseURL: string // required — path prefix to match, must start with "/"
|
|
110
|
+
proxy: Proxy // required — upstream proxy settings
|
|
111
|
+
rateLimit?: RateLimit // optional — per-route rate limiting
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### `Proxy`
|
|
116
|
+
|
|
117
|
+
| Field | Type | Required | Description |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `target` | `string` | ✅ | Target upstream URL (must be a valid URL). |
|
|
120
|
+
| `changeOrigin` | `boolean` | — | Rewrite the `Host` header to the target origin. |
|
|
121
|
+
| `pathRewrite` | `{ [pattern]: replacement }` | — | Regex path rewrite rules applied before forwarding. |
|
|
122
|
+
| `headers` | `{ [name]: value }` | — | Extra headers added to every forwarded request. |
|
|
123
|
+
| `isSecure` | `boolean` | — | Verify the upstream TLS certificate. |
|
|
124
|
+
| `method` | `string` | — | Override the HTTP method forwarded to the upstream. |
|
|
125
|
+
| `timeout` | `number` | — | Proxy request timeout in milliseconds. |
|
|
126
|
+
|
|
127
|
+
#### `RateLimit`
|
|
128
|
+
|
|
129
|
+
| Field | Type | Required | Description |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| `max` | `number` | ✅ | Maximum number of requests allowed per window. |
|
|
132
|
+
| `windowMs` | `number` | ✅ | Time window in milliseconds. |
|
|
133
|
+
| `statusCode` | `number` | — | HTTP status returned when the limit is exceeded (default: `429`). |
|
|
134
|
+
| `message` | `string` | — | Response message when the limit is exceeded (default: `"Too many requests"`). |
|
|
135
|
+
|
|
136
|
+
Responses include standard `RateLimit-*` headers (RFC draft-8).
|
|
137
|
+
|
|
138
|
+
#### Example `routes.json`
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
[
|
|
142
|
+
{
|
|
143
|
+
"baseURL": "/users",
|
|
144
|
+
"proxy": {
|
|
145
|
+
"target": "http://users-service:3001",
|
|
146
|
+
"changeOrigin": true,
|
|
147
|
+
"pathRewrite": { "^/users": "" }
|
|
148
|
+
},
|
|
149
|
+
"rateLimit": {
|
|
150
|
+
"max": 100,
|
|
151
|
+
"windowMs": 60000,
|
|
152
|
+
"statusCode": 429,
|
|
153
|
+
"message": "Too many requests. Please try again in a minute."
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"baseURL": "/orders",
|
|
158
|
+
"proxy": {
|
|
159
|
+
"target": "http://orders-service:3002",
|
|
160
|
+
"changeOrigin": true,
|
|
161
|
+
"pathRewrite": { "^/orders": "" },
|
|
162
|
+
"headers": {
|
|
163
|
+
"X-Internal-Source": "api-gateway"
|
|
164
|
+
},
|
|
165
|
+
"timeout": 5000
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Config is validated with [Zod](https://zod.dev) at startup. If any route is invalid the process exits immediately with a detailed per-field error message.
|
|
172
|
+
|
|
173
|
+
Routes are loaded from two sources and **merged**:
|
|
174
|
+
|
|
175
|
+
1. `ROUTES_FILE_PATH` — JSON file on disk (missing file is a warning, not an error)
|
|
176
|
+
2. `ROUTES` — JSON array in an environment variable
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Running the Gateway
|
|
181
|
+
|
|
182
|
+
### Development (hot-reload)
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
pnpm dev
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Uses `ts-node-dev` to transpile on the fly and restart on file changes. Logs are pretty-printed via `pino-pretty`.
|
|
189
|
+
|
|
190
|
+
### Production
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
# Compile TypeScript
|
|
194
|
+
pnpm build
|
|
195
|
+
|
|
196
|
+
# Run the compiled output
|
|
197
|
+
pnpm start
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
In production (`NODE_ENV=production`) logs are emitted as newline-delimited JSON suitable for log aggregators (Datadog, Loki, CloudWatch, etc.).
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Health Check
|
|
205
|
+
|
|
206
|
+
The gateway exposes a built-in health check endpoint that is always available, independent of the configured proxy routes.
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
GET /health
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
If `GATEWAY_PREFIX` is set, the endpoint is available at `<GATEWAY_PREFIX>/health`.
|
|
213
|
+
|
|
214
|
+
**Response `200 OK`:**
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"status": "ok",
|
|
219
|
+
"uptime": 42,
|
|
220
|
+
"version": "1.0.0",
|
|
221
|
+
"timestamp": "2026-06-18T00:00:00.000Z"
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
| Field | Description |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `status` | Always `"ok"` when the process is alive. |
|
|
228
|
+
| `uptime` | Seconds since the gateway process started. |
|
|
229
|
+
| `version` | Value of `npm_package_version` (set automatically by npm/pnpm). |
|
|
230
|
+
| `timestamp` | ISO 8601 timestamp of the response. |
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Project Structure
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
src/apps/api-gateway/
|
|
238
|
+
├── index.ts # Entry point — bootstraps the app, registers process signals
|
|
239
|
+
├── App.ts # Thin lifecycle wrapper (start / stop)
|
|
240
|
+
├── Server.ts # HTTP server creation and prefix mounting
|
|
241
|
+
├── logger.ts # Pino logger singleton
|
|
242
|
+
│
|
|
243
|
+
├── config/
|
|
244
|
+
│ ├── app-env.ts # Aggregates all config modules into a single AppEnv object
|
|
245
|
+
│ ├── env/config.ts # NODE_ENV → isDev flag
|
|
246
|
+
│ ├── gateway/config.ts # GATEWAY_PORT, GATEWAY_PREFIX
|
|
247
|
+
│ ├── cors/config.ts # CORS_ORIGINS, CORS_METHODS, CORS_HEADERS
|
|
248
|
+
│ └── routes/config.ts # ROUTES_FILE_PATH
|
|
249
|
+
│
|
|
250
|
+
├── routes/
|
|
251
|
+
│ ├── Router.ts # Middleware pipeline (logging → security → CORS → body → proxy → errors)
|
|
252
|
+
│ ├── ProxyManager.ts # Reads, validates, and registers proxy routes
|
|
253
|
+
│ ├── RouteValidator.ts # Zod schemas for Gateway / Proxy / RateLimit types
|
|
254
|
+
│ └── HealthRouter.ts # GET /health handler
|
|
255
|
+
│
|
|
256
|
+
└── types/
|
|
257
|
+
├── gateway.d.ts # Gateway type
|
|
258
|
+
├── proxy.d.ts # Proxy type
|
|
259
|
+
└── rate-limit.d.ts # RateLimit type
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Example Project
|
|
265
|
+
|
|
266
|
+
`examples/basic/` contains a self-contained demo with two mock upstream services and a pre-built gateway config.
|
|
267
|
+
|
|
268
|
+
### What's included
|
|
269
|
+
|
|
270
|
+
| File | Description |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `routes.json` | Gateway config with two routes (`/users`, `/products`) each with rate limiting and path rewriting |
|
|
273
|
+
| `.env` | Gateway environment for the example |
|
|
274
|
+
| `upstream-users.js` | Mock Users service on port `4001` |
|
|
275
|
+
| `upstream-products.js` | Mock Products service on port `4002` |
|
|
276
|
+
| `run.sh` | Starts all three processes and stops them together on Ctrl+C |
|
|
277
|
+
|
|
278
|
+
### Running the example
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
pnpm example
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
This copies `examples/basic/.env` to the project root and starts all three services. Once running:
|
|
285
|
+
|
|
286
|
+
| Endpoint | Description |
|
|
287
|
+
|---|---|
|
|
288
|
+
| `http://localhost:3000/health` | Gateway health check |
|
|
289
|
+
| `http://localhost:3000/users` | Proxied to Users service |
|
|
290
|
+
| `http://localhost:3000/products` | Proxied to Products service |
|
|
291
|
+
|
|
292
|
+
### Users service endpoints (`/users`)
|
|
293
|
+
|
|
294
|
+
| Method | Path | Description |
|
|
295
|
+
|---|---|---|
|
|
296
|
+
| `GET` | `/users` | List all users |
|
|
297
|
+
| `GET` | `/users/:id` | Get a user by ID |
|
|
298
|
+
| `POST` | `/users` | Create a user (body: `{ name, email }`) |
|
|
299
|
+
| `DELETE` | `/users/:id` | Delete a user by ID |
|
|
300
|
+
|
|
301
|
+
### Products service endpoints (`/products`)
|
|
302
|
+
|
|
303
|
+
| Method | Path | Description |
|
|
304
|
+
|---|---|---|
|
|
305
|
+
| `GET` | `/products` | List all products |
|
|
306
|
+
| `GET` | `/products/:id` | Get a product by ID |
|
|
307
|
+
| `POST` | `/products` | Create a product (body: `{ name, price }`) |
|
|
308
|
+
| `PATCH` | `/products/:id/stock` | Adjust stock (body: `{ quantity: number }`) |
|
|
309
|
+
|
|
310
|
+
### Example requests
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
# List users
|
|
314
|
+
curl http://localhost:3000/users
|
|
315
|
+
|
|
316
|
+
# Create a user
|
|
317
|
+
curl -X POST http://localhost:3000/users \
|
|
318
|
+
-H "Content-Type: application/json" \
|
|
319
|
+
-d '{"name": "Alice", "email": "alice@example.com"}'
|
|
320
|
+
|
|
321
|
+
# Get a product
|
|
322
|
+
curl http://localhost:3000/products/1
|
|
323
|
+
|
|
324
|
+
# Update product stock
|
|
325
|
+
curl -X PATCH http://localhost:3000/products/1/stock \
|
|
326
|
+
-H "Content-Type: application/json" \
|
|
327
|
+
-d '{"quantity": 25}'
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## Architecture
|
|
333
|
+
|
|
334
|
+
### Request lifecycle
|
|
335
|
+
|
|
336
|
+
```
|
|
337
|
+
Client
|
|
338
|
+
│
|
|
339
|
+
▼
|
|
340
|
+
Express app
|
|
341
|
+
│
|
|
342
|
+
├─ pino-http structured request/response logging
|
|
343
|
+
├─ helmet security headers (CSP, HSTS, X-Frame-Options, …)
|
|
344
|
+
├─ cors configurable origin / method / header policy
|
|
345
|
+
├─ express.json body parsing
|
|
346
|
+
├─ compression gzip response compression
|
|
347
|
+
│
|
|
348
|
+
├─ GET /health health check — short-circuits here
|
|
349
|
+
│
|
|
350
|
+
├─ express-rate-limit per-route request throttling (applied per baseURL)
|
|
351
|
+
├─ http-proxy-middleware proxies request to upstream, forwards body
|
|
352
|
+
│
|
|
353
|
+
└─ error handler catches unhandled errors → 500 JSON response
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### Startup sequence
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
index.ts
|
|
360
|
+
│
|
|
361
|
+
├─ dotenv.config() load .env before any module reads process.env
|
|
362
|
+
├─ new App()
|
|
363
|
+
│ └─ new Server()
|
|
364
|
+
│ ├─ appEnv resolved config modules read from process.env
|
|
365
|
+
│ └─ new Router()
|
|
366
|
+
│
|
|
367
|
+
└─ app.start()
|
|
368
|
+
├─ router.init() async: reads routes file, validates, registers middleware
|
|
369
|
+
└─ httpServer.listen() starts accepting connections only after routes are ready
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Route loading
|
|
373
|
+
|
|
374
|
+
Routes are loaded from two sources at startup and merged into a single array before validation:
|
|
375
|
+
|
|
376
|
+
```
|
|
377
|
+
ROUTES_FILE_PATH (JSON file) ──┐
|
|
378
|
+
├──► merge ──► Zod validation ──► register proxy routes
|
|
379
|
+
ROUTES (env var JSON array) ──┘
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
If either source is missing or contains invalid JSON it is skipped with a warning. If the merged result fails Zod validation, the process exits with a descriptive error.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.App = void 0;
|
|
13
|
+
const Server_1 = require("./Server");
|
|
14
|
+
class App {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.server = new Server_1.Server();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Start the HTTP server
|
|
20
|
+
*/
|
|
21
|
+
start() {
|
|
22
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
23
|
+
return yield this.server.start();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Configure security headers using Helmet
|
|
28
|
+
*/
|
|
29
|
+
stop() {
|
|
30
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
31
|
+
return yield this.server.stop();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
exports.App = App;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Express } from "express";
|
|
2
|
+
export declare class Server {
|
|
3
|
+
private readonly app;
|
|
4
|
+
private readonly router;
|
|
5
|
+
private readonly httpServer;
|
|
6
|
+
private readonly port;
|
|
7
|
+
private readonly prefix;
|
|
8
|
+
constructor();
|
|
9
|
+
/**
|
|
10
|
+
* Register all middleware and proxy routes without opening a port.
|
|
11
|
+
* Call this before start() or use it directly in tests with getApp().
|
|
12
|
+
*/
|
|
13
|
+
init(): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Returns the underlying Express application.
|
|
16
|
+
* Useful for integration tests via supertest without binding to a port.
|
|
17
|
+
*/
|
|
18
|
+
getApp(): Express;
|
|
19
|
+
/**
|
|
20
|
+
* Initialise routes then start the HTTP server.
|
|
21
|
+
*/
|
|
22
|
+
start(): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Stop the HTTP server gracefully
|
|
25
|
+
*/
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.Server = void 0;
|
|
16
|
+
const express_1 = __importDefault(require("express"));
|
|
17
|
+
const http_1 = require("http");
|
|
18
|
+
const Router_1 = require("./routes/Router");
|
|
19
|
+
const app_env_1 = require("./config/app-env");
|
|
20
|
+
const logger_1 = require("./logger");
|
|
21
|
+
class Server {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.port = app_env_1.appEnv.gateway.port;
|
|
24
|
+
this.prefix = app_env_1.appEnv.gateway.prefix;
|
|
25
|
+
this.router = new Router_1.Router();
|
|
26
|
+
this.app = (0, express_1.default)();
|
|
27
|
+
this.httpServer = (0, http_1.createServer)(this.app);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Register all middleware and proxy routes without opening a port.
|
|
31
|
+
* Call this before start() or use it directly in tests with getApp().
|
|
32
|
+
*/
|
|
33
|
+
init() {
|
|
34
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
35
|
+
yield this.router.init();
|
|
36
|
+
this.app.use(this.prefix, this.router.getRouter());
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Returns the underlying Express application.
|
|
41
|
+
* Useful for integration tests via supertest without binding to a port.
|
|
42
|
+
*/
|
|
43
|
+
getApp() {
|
|
44
|
+
return this.app;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Initialise routes then start the HTTP server.
|
|
48
|
+
*/
|
|
49
|
+
start() {
|
|
50
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
51
|
+
yield this.init();
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
this.httpServer.listen(this.port, () => {
|
|
54
|
+
logger_1.logger.info(`Gateway started on port ${this.port} (prefix: ${this.prefix})`);
|
|
55
|
+
resolve();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Stop the HTTP server gracefully
|
|
62
|
+
*/
|
|
63
|
+
stop() {
|
|
64
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
this.httpServer.close((error) => {
|
|
67
|
+
if (error) {
|
|
68
|
+
logger_1.logger.warn({ err: error }, "Error while stopping server");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
logger_1.logger.info("Gateway stopped");
|
|
72
|
+
}
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
exports.Server = Server;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type GatewayConfig } from "./gateway/config";
|
|
2
|
+
import { type CorsConfig } from "./cors/config";
|
|
3
|
+
import { type RoutesConfig } from "./routes/config";
|
|
4
|
+
import { type EnvConfig } from "./env/config";
|
|
5
|
+
export type AppEnv = {
|
|
6
|
+
env: EnvConfig;
|
|
7
|
+
gateway: GatewayConfig;
|
|
8
|
+
cors: CorsConfig;
|
|
9
|
+
routes: RoutesConfig;
|
|
10
|
+
};
|
|
11
|
+
export declare const appEnv: AppEnv;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.appEnv = void 0;
|
|
4
|
+
const config_1 = require("./gateway/config");
|
|
5
|
+
const config_2 = require("./cors/config");
|
|
6
|
+
const config_3 = require("./routes/config");
|
|
7
|
+
const config_4 = require("./env/config");
|
|
8
|
+
exports.appEnv = {
|
|
9
|
+
env: config_4.envConfig,
|
|
10
|
+
gateway: config_1.gatewayConfig,
|
|
11
|
+
cors: config_2.corsConfig,
|
|
12
|
+
routes: config_3.routesConfig,
|
|
13
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.corsConfig = void 0;
|
|
4
|
+
const { CORS_ORIGINS, CORS_METHODS, CORS_HEADERS } = process.env;
|
|
5
|
+
exports.corsConfig = {
|
|
6
|
+
origins: (CORS_ORIGINS === null || CORS_ORIGINS === void 0 ? void 0 : CORS_ORIGINS.split(",").map((origin) => origin.trim())) || "*",
|
|
7
|
+
methods: CORS_METHODS
|
|
8
|
+
? CORS_METHODS.split(",").map((method) => method.trim())
|
|
9
|
+
: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
|
10
|
+
allowedHeaders: CORS_HEADERS
|
|
11
|
+
? CORS_HEADERS.split(",").map((header) => header.trim())
|
|
12
|
+
: ["Content-Type", "Authorization"],
|
|
13
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.gatewayConfig = exports.DEFAULT_PORT = void 0;
|
|
4
|
+
const { GATEWAY_PREFIX, GATEWAY_PORT, PORT } = process.env;
|
|
5
|
+
exports.DEFAULT_PORT = 3000;
|
|
6
|
+
exports.gatewayConfig = {
|
|
7
|
+
prefix: GATEWAY_PREFIX || '/',
|
|
8
|
+
port: Number(GATEWAY_PORT || PORT) || exports.DEFAULT_PORT,
|
|
9
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
4
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
5
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
6
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
7
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
8
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
9
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
const dotenv_1 = require("dotenv");
|
|
14
|
+
(0, dotenv_1.config)();
|
|
15
|
+
const App_1 = require("./App");
|
|
16
|
+
const logger_1 = require("./logger");
|
|
17
|
+
function handleError(error) {
|
|
18
|
+
logger_1.logger.error({ err: error }, "Fatal startup error");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Bootstrap the application.
|
|
23
|
+
*
|
|
24
|
+
* This function creates a new instance of the App class and starts it.
|
|
25
|
+
*
|
|
26
|
+
* @returns {void}
|
|
27
|
+
*/
|
|
28
|
+
function bootstrap() {
|
|
29
|
+
const app = new App_1.App();
|
|
30
|
+
app.start().catch(handleError);
|
|
31
|
+
// Handle process termination signals
|
|
32
|
+
process.on("SIGINT", () => __awaiter(this, void 0, void 0, function* () {
|
|
33
|
+
yield app.stop();
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}));
|
|
36
|
+
process.on("uncaughtException", (error) => __awaiter(this, void 0, void 0, function* () {
|
|
37
|
+
logger_1.logger.error({ err: error }, "uncaughtException");
|
|
38
|
+
try {
|
|
39
|
+
yield app.stop();
|
|
40
|
+
}
|
|
41
|
+
catch (_a) {
|
|
42
|
+
// ignore stop errors during crash shutdown
|
|
43
|
+
}
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
bootstrap();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.logger = void 0;
|
|
7
|
+
const pino_1 = __importDefault(require("pino"));
|
|
8
|
+
const app_env_1 = require("./config/app-env");
|
|
9
|
+
exports.logger = (0, pino_1.default)(Object.assign({ level: process.env.LOG_LEVEL || "info" }, (app_env_1.appEnv.env.isDev && {
|
|
10
|
+
transport: {
|
|
11
|
+
target: "pino-pretty",
|
|
12
|
+
options: {
|
|
13
|
+
colorize: true,
|
|
14
|
+
ignore: "pid,hostname",
|
|
15
|
+
translateTime: "SYS:HH:MM:ss",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
})));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createHealthRouter = createHealthRouter;
|
|
4
|
+
const express_1 = require("express");
|
|
5
|
+
const http_status_codes_1 = require("http-status-codes");
|
|
6
|
+
const startTime = Date.now();
|
|
7
|
+
function createHealthRouter() {
|
|
8
|
+
const router = (0, express_1.Router)();
|
|
9
|
+
router.get("/health", (_req, res) => {
|
|
10
|
+
res.status(http_status_codes_1.StatusCodes.OK).json({
|
|
11
|
+
status: "ok",
|
|
12
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
13
|
+
version: process.env.npm_package_version || "unknown",
|
|
14
|
+
timestamp: new Date().toISOString(),
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
return router;
|
|
18
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Router } from "express";
|
|
2
|
+
export declare class ProxyManager {
|
|
3
|
+
private readonly router;
|
|
4
|
+
private readonly filePath;
|
|
5
|
+
constructor(router: Router);
|
|
6
|
+
/**
|
|
7
|
+
* Register all proxy routes in the application
|
|
8
|
+
*/
|
|
9
|
+
registerProxyRoutes(): Promise<void>;
|
|
10
|
+
private readRoutes;
|
|
11
|
+
private readFileRoutes;
|
|
12
|
+
private readEnvRoutes;
|
|
13
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.ProxyManager = void 0;
|
|
16
|
+
const http_proxy_middleware_1 = require("http-proxy-middleware");
|
|
17
|
+
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
|
|
18
|
+
const http_status_codes_1 = require("http-status-codes");
|
|
19
|
+
const promises_1 = require("node:fs/promises");
|
|
20
|
+
const RouteValidator_1 = require("./RouteValidator");
|
|
21
|
+
const logger_1 = require("../logger");
|
|
22
|
+
const app_env_1 = require("../config/app-env");
|
|
23
|
+
class ProxyManager {
|
|
24
|
+
constructor(router) {
|
|
25
|
+
this.router = router;
|
|
26
|
+
this.filePath = app_env_1.appEnv.routes.filePath;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Register all proxy routes in the application
|
|
30
|
+
*/
|
|
31
|
+
registerProxyRoutes() {
|
|
32
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
33
|
+
const routes = yield this.readRoutes();
|
|
34
|
+
if (routes.length === 0) {
|
|
35
|
+
logger_1.logger.warn("No proxy routes configured");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
routes.forEach((route) => {
|
|
39
|
+
var _a, _b;
|
|
40
|
+
// Apply per-route rate limiting when configured
|
|
41
|
+
if (route.rateLimit) {
|
|
42
|
+
this.router.use(route.baseURL, (0, express_rate_limit_1.default)({
|
|
43
|
+
windowMs: route.rateLimit.windowMs,
|
|
44
|
+
limit: route.rateLimit.max,
|
|
45
|
+
statusCode: (_a = route.rateLimit.statusCode) !== null && _a !== void 0 ? _a : http_status_codes_1.StatusCodes.TOO_MANY_REQUESTS,
|
|
46
|
+
message: (_b = route.rateLimit.message) !== null && _b !== void 0 ? _b : "Too many requests",
|
|
47
|
+
standardHeaders: true,
|
|
48
|
+
legacyHeaders: false,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
this.router.use(route.baseURL, (0, http_proxy_middleware_1.createProxyMiddleware)(Object.assign(Object.assign({}, route.proxy), { on: {
|
|
52
|
+
// Re-stream the body that express.json() already consumed so the
|
|
53
|
+
// upstream receives the request body correctly on POST/PUT/PATCH.
|
|
54
|
+
proxyReq: http_proxy_middleware_1.fixRequestBody,
|
|
55
|
+
} })));
|
|
56
|
+
logger_1.logger.info({ baseURL: route.baseURL, target: route.proxy.target }, "Registered proxy route");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
readRoutes() {
|
|
61
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
62
|
+
const fileRoutes = yield this.readFileRoutes();
|
|
63
|
+
const envRoutes = this.readEnvRoutes();
|
|
64
|
+
const merged = [...fileRoutes, ...envRoutes];
|
|
65
|
+
return (0, RouteValidator_1.validateRoutes)(merged);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
readFileRoutes() {
|
|
69
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
70
|
+
try {
|
|
71
|
+
const content = yield (0, promises_1.readFile)(this.filePath, "utf-8");
|
|
72
|
+
return JSON.parse(content);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code === "ENOENT") {
|
|
76
|
+
logger_1.logger.debug({ filePath: this.filePath }, "Routes file not found, skipping");
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
logger_1.logger.error({ err: error, filePath: this.filePath }, "Failed to read routes file");
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
readEnvRoutes() {
|
|
85
|
+
const raw = process.env.ROUTES;
|
|
86
|
+
if (!raw)
|
|
87
|
+
return [];
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(raw);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
logger_1.logger.error({ err: error }, "Failed to parse ROUTES env var as JSON, skipping");
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
exports.ProxyManager = ProxyManager;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Gateway } from "../types/gateway";
|
|
3
|
+
export declare const GatewaysSchema: z.ZodArray<z.ZodObject<{
|
|
4
|
+
baseURL: z.ZodString;
|
|
5
|
+
proxy: z.ZodObject<{
|
|
6
|
+
target: z.ZodURL;
|
|
7
|
+
isSecure: z.ZodOptional<z.ZodBoolean>;
|
|
8
|
+
changeOrigin: z.ZodOptional<z.ZodBoolean>;
|
|
9
|
+
pathRewrite: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
10
|
+
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
11
|
+
method: z.ZodOptional<z.ZodString>;
|
|
12
|
+
timeout: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
rateLimit: z.ZodOptional<z.ZodObject<{
|
|
15
|
+
max: z.ZodNumber;
|
|
16
|
+
windowMs: z.ZodNumber;
|
|
17
|
+
statusCode: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
message: z.ZodOptional<z.ZodString>;
|
|
19
|
+
}, z.core.$strip>>;
|
|
20
|
+
}, z.core.$strip>>;
|
|
21
|
+
export declare function validateRoutes(data: unknown): Gateway[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GatewaysSchema = void 0;
|
|
4
|
+
exports.validateRoutes = validateRoutes;
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const ProxySchema = zod_1.z.object({
|
|
7
|
+
target: zod_1.z.url("Proxy target must be a valid URL"),
|
|
8
|
+
isSecure: zod_1.z.boolean().optional(),
|
|
9
|
+
changeOrigin: zod_1.z.boolean().optional(),
|
|
10
|
+
pathRewrite: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
|
|
11
|
+
headers: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
|
|
12
|
+
method: zod_1.z.string().optional(),
|
|
13
|
+
timeout: zod_1.z.number().positive("Proxy timeout must be a positive number").optional(),
|
|
14
|
+
});
|
|
15
|
+
const RateLimitSchema = zod_1.z.object({
|
|
16
|
+
max: zod_1.z.number().positive("Rate limit max must be a positive number"),
|
|
17
|
+
windowMs: zod_1.z.number().positive("Rate limit windowMs must be a positive number"),
|
|
18
|
+
statusCode: zod_1.z.number().optional(),
|
|
19
|
+
message: zod_1.z.string().optional(),
|
|
20
|
+
});
|
|
21
|
+
const GatewaySchema = zod_1.z.object({
|
|
22
|
+
baseURL: zod_1.z.string().startsWith("/", "baseURL must start with /"),
|
|
23
|
+
proxy: ProxySchema,
|
|
24
|
+
rateLimit: RateLimitSchema.optional(),
|
|
25
|
+
});
|
|
26
|
+
exports.GatewaysSchema = zod_1.z.array(GatewaySchema);
|
|
27
|
+
function validateRoutes(data) {
|
|
28
|
+
const result = exports.GatewaysSchema.safeParse(data);
|
|
29
|
+
if (!result.success) {
|
|
30
|
+
const formatted = result.error.issues
|
|
31
|
+
.map((issue) => ` [${issue.path.join(".")}] ${issue.message}`)
|
|
32
|
+
.join("\n");
|
|
33
|
+
throw new Error(`Invalid route configuration:\n${formatted}`);
|
|
34
|
+
}
|
|
35
|
+
return result.data;
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Router as ExpressRouter } from "express";
|
|
2
|
+
export declare class Router {
|
|
3
|
+
private readonly router;
|
|
4
|
+
constructor();
|
|
5
|
+
/**
|
|
6
|
+
* Get the router instance for the application
|
|
7
|
+
*/
|
|
8
|
+
getRouter(): ExpressRouter;
|
|
9
|
+
/**
|
|
10
|
+
* Initialise all middleware and routes. Must be awaited before the HTTP
|
|
11
|
+
* server starts listening so that proxy routes are registered in time.
|
|
12
|
+
*/
|
|
13
|
+
init(): Promise<void>;
|
|
14
|
+
private configureCors;
|
|
15
|
+
private configureBodyParser;
|
|
16
|
+
private configureProxyManager;
|
|
17
|
+
private configureErrorHandler;
|
|
18
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
36
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
37
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
38
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
39
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
40
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
41
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.Router = void 0;
|
|
49
|
+
const express_1 = __importStar(require("express"));
|
|
50
|
+
const http_status_codes_1 = require("http-status-codes");
|
|
51
|
+
const cors_1 = __importDefault(require("cors"));
|
|
52
|
+
const compression_1 = __importDefault(require("compression"));
|
|
53
|
+
const helmet_1 = __importDefault(require("helmet"));
|
|
54
|
+
const pino_http_1 = __importDefault(require("pino-http"));
|
|
55
|
+
const ProxyManager_1 = require("./ProxyManager");
|
|
56
|
+
const HealthRouter_1 = require("./HealthRouter");
|
|
57
|
+
const app_env_1 = require("../config/app-env");
|
|
58
|
+
const logger_1 = require("../logger");
|
|
59
|
+
class Router {
|
|
60
|
+
constructor() {
|
|
61
|
+
this.router = (0, express_1.Router)();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get the router instance for the application
|
|
65
|
+
*/
|
|
66
|
+
getRouter() {
|
|
67
|
+
return this.router;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Initialise all middleware and routes. Must be awaited before the HTTP
|
|
71
|
+
* server starts listening so that proxy routes are registered in time.
|
|
72
|
+
*/
|
|
73
|
+
init() {
|
|
74
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
75
|
+
// Structured HTTP request logging
|
|
76
|
+
this.router.use((0, pino_http_1.default)({ logger: logger_1.logger }));
|
|
77
|
+
// Security headers (full helmet defaults)
|
|
78
|
+
this.router.use((0, helmet_1.default)());
|
|
79
|
+
// Configurable CORS
|
|
80
|
+
this.configureCors();
|
|
81
|
+
// Body parsing + gzip compression
|
|
82
|
+
this.configureBodyParser();
|
|
83
|
+
// Health check
|
|
84
|
+
this.router.use((0, HealthRouter_1.createHealthRouter)());
|
|
85
|
+
// Proxy routes (async — reads config file / env var)
|
|
86
|
+
yield this.configureProxyManager();
|
|
87
|
+
// Error handler must be registered last
|
|
88
|
+
this.configureErrorHandler();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
configureCors() {
|
|
92
|
+
const { origins, methods, allowedHeaders } = app_env_1.appEnv.cors;
|
|
93
|
+
this.router.use((0, cors_1.default)({
|
|
94
|
+
origin: origins,
|
|
95
|
+
methods,
|
|
96
|
+
allowedHeaders,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
configureBodyParser() {
|
|
100
|
+
this.router.use(express_1.default.json());
|
|
101
|
+
this.router.use(express_1.default.urlencoded({ extended: true }));
|
|
102
|
+
this.router.use((0, compression_1.default)());
|
|
103
|
+
}
|
|
104
|
+
configureProxyManager() {
|
|
105
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
106
|
+
const proxyManager = new ProxyManager_1.ProxyManager(this.router);
|
|
107
|
+
yield proxyManager.registerProxyRoutes();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
configureErrorHandler() {
|
|
111
|
+
this.router.use((error, _req, res, _next) => {
|
|
112
|
+
logger_1.logger.error({ err: error }, "Unhandled error");
|
|
113
|
+
res.status(http_status_codes_1.StatusCodes.INTERNAL_SERVER_ERROR).json({
|
|
114
|
+
error: "Internal Server Error",
|
|
115
|
+
message: error.message,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
exports.Router = Router;
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@derian-cordoba/api-gateway",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A generic, configuration-driven HTTP API gateway with per-route rate limiting, structured logging, and security headers",
|
|
5
|
+
"main": "./dist/src/apps/api-gateway/index.js",
|
|
6
|
+
"types": "./dist/src/apps/api-gateway/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"api-gateway": "./dist/src/apps/api-gateway/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
".env.example",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0",
|
|
17
|
+
"pnpm": ">=10.0.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"api-gateway",
|
|
21
|
+
"gateway",
|
|
22
|
+
"proxy",
|
|
23
|
+
"reverse-proxy",
|
|
24
|
+
"express",
|
|
25
|
+
"rate-limiting",
|
|
26
|
+
"typescript",
|
|
27
|
+
"nodejs"
|
|
28
|
+
],
|
|
29
|
+
"author": "derian-cordoba",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"packageManager": "pnpm@10.6.5",
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "NODE_ENV=development ts-node-dev --ignore-watch node_modules --respawn --transpile-only src/apps/api-gateway/index.ts",
|
|
34
|
+
"build": "rm -rf ./dist && tsc -p tsconfig.prod.json",
|
|
35
|
+
"postbuild": "chmod +x dist/src/apps/api-gateway/index.js",
|
|
36
|
+
"start": "node dist/src/apps/api-gateway/index.js",
|
|
37
|
+
"prepare": "pnpm build",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"example": "bash examples/basic/run.sh"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/compression": "^1.8.0",
|
|
44
|
+
"@types/cors": "^2.8.18",
|
|
45
|
+
"@types/express": "^5.0.2",
|
|
46
|
+
"@types/node": "^22.15.21",
|
|
47
|
+
"@types/supertest": "^7.2.0",
|
|
48
|
+
"pino-pretty": "^13.1.3",
|
|
49
|
+
"supertest": "^7.2.2",
|
|
50
|
+
"ts-node-dev": "^2.0.0",
|
|
51
|
+
"typescript": "^5.8.3",
|
|
52
|
+
"vitest": "^4.1.9"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"compression": "^1.8.0",
|
|
56
|
+
"cors": "^2.8.5",
|
|
57
|
+
"dotenv": "^16.5.0",
|
|
58
|
+
"express": "^5.1.0",
|
|
59
|
+
"express-rate-limit": "^8.5.2",
|
|
60
|
+
"helmet": "^8.1.0",
|
|
61
|
+
"http-proxy-middleware": "^3.0.5",
|
|
62
|
+
"http-status-codes": "^2.3.0",
|
|
63
|
+
"pino": "^10.3.1",
|
|
64
|
+
"pino-http": "^11.0.0",
|
|
65
|
+
"zod": "^4.4.3"
|
|
66
|
+
},
|
|
67
|
+
"overrides": {
|
|
68
|
+
"rimraf": "^6.0.1"
|
|
69
|
+
}
|
|
70
|
+
}
|